2026-03-03 22:40:18 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* @fileoverview Core UI controller for Codeman — tab-based terminal manager with xterm.js.
|
|
|
|
|
|
*
|
2026-03-14 21:24:22 +01:00
|
|
|
|
* Defines the CodemanApp class (constructor, init, SSE connection, session lifecycle, tabs,
|
|
|
|
|
|
* navigation). Domain-specific methods are mixed in from separate modules via Object.assign:
|
2026-03-03 22:40:18 +01:00
|
|
|
|
*
|
2026-03-14 21:24:22 +01:00
|
|
|
|
* terminal-ui.js — Terminal setup, rendering pipeline, controls
|
|
|
|
|
|
* respawn-ui.js — Respawn banner, countdown timers, presets, run summary
|
|
|
|
|
|
* ralph-panel.js — Ralph state panel, fix_plan, plan versioning
|
|
|
|
|
|
* settings-ui.js — App settings, visibility, web push, lifecycle log, tunnel/QR, help
|
|
|
|
|
|
* panels-ui.js — Subagent panel, agent teams, project insights, file browser, log viewer,
|
|
|
|
|
|
* image popups, monitor, token stats, toast, system stats
|
|
|
|
|
|
* session-ui.js — Quick start, session options modal, case settings, mobile case picker
|
|
|
|
|
|
* ralph-wizard.js — Ralph Loop wizard modal
|
|
|
|
|
|
* api-client.js — API helper methods (fetch wrappers)
|
|
|
|
|
|
* subagent-windows.js — Floating subagent terminal windows
|
2026-03-03 22:40:18 +01:00
|
|
|
|
*
|
2026-03-14 21:24:22 +01:00
|
|
|
|
* ═══ Sections in this file ═══
|
2026-03-03 22:40:18 +01:00
|
|
|
|
*
|
2026-03-14 21:24:22 +01:00
|
|
|
|
* SSE Handler Map — Event-to-method routing table (resolves at runtime via `this`)
|
|
|
|
|
|
* CodemanApp Class — Constructor and all state initialization (~80 properties)
|
|
|
|
|
|
* Pending Hooks — Hook state machine for tab alerts
|
|
|
|
|
|
* Init — App bootstrap, mobile setup, WebGL init
|
|
|
|
|
|
* Event Listeners — Keyboard shortcuts, resize, beforeunload
|
|
|
|
|
|
* SSE Connection — connectSSE with exponential backoff (1-30s)
|
|
|
|
|
|
* Core SSE Event Handlers — Session lifecycle, scheduled runs (~20 handlers)
|
|
|
|
|
|
* Connection Status — Online detection, input queuing, state sync
|
|
|
|
|
|
* WebSocket Terminal I/O — Low-latency WS bypass for terminal input
|
|
|
|
|
|
* Session Tabs — Tab rendering, selection, drag-and-drop reordering
|
|
|
|
|
|
* Tab Order & Drag-and-Drop — Persistent ordering with localStorage sync
|
|
|
|
|
|
* Session Lifecycle — Select, close, navigate, rename, cleanup
|
|
|
|
|
|
* Navigation — goHome
|
|
|
|
|
|
* Kill Sessions — Kill active/all sessions
|
|
|
|
|
|
* Timer / Tokens — Session timer, token/cost display
|
|
|
|
|
|
* Module Init — localStorage migration, app instantiation
|
2026-03-03 22:40:18 +01:00
|
|
|
|
*
|
|
|
|
|
|
* @class CodemanApp
|
|
|
|
|
|
* @globals {CodemanApp} app - Singleton instance (also on window.app)
|
|
|
|
|
|
*
|
2026-03-11 19:36:58 +01:00
|
|
|
|
* @dependency constants.js (SSE_EVENTS, timing constants, escapeHtml, DEC_SYNC_STRIP_RE)
|
2026-03-03 22:40:18 +01:00
|
|
|
|
* @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)
|
2026-03-14 21:24:22 +01:00
|
|
|
|
* @loadorder 6 of 15 — loaded after keyboard-accessory.js, before terminal-ui.js
|
2026-03-03 22:40:18 +01:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-02-26 16:44:34 +01:00
|
|
|
|
// Codeman App - Tab-based Terminal UI
|
2026-03-01 04:57:37 +01:00
|
|
|
|
// 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
|
2026-01-26 14:27:21 +01:00
|
|
|
|
|
2026-03-05 12:42:32 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Global Error & Performance Diagnostics
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
2026-03-05 16:44:17 +01:00
|
|
|
|
// 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);
|
2026-03-05 12:42:32 +01:00
|
|
|
|
|
|
|
|
|
|
window.addEventListener('error', (e) => {
|
2026-03-05 16:44:17 +01:00
|
|
|
|
_crashDiag.log(`ERROR: ${e.message} at ${e.filename}:${e.lineno}`);
|
2026-03-05 12:42:32 +01:00
|
|
|
|
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) => {
|
2026-03-05 16:44:17 +01:00
|
|
|
|
_crashDiag.log(`UNHANDLED: ${e.reason?.message || e.reason}`);
|
2026-03-05 12:42:32 +01:00
|
|
|
|
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) {
|
2026-03-05 16:44:17 +01:00
|
|
|
|
_crashDiag.log(`LONG_TASK: ${entry.duration.toFixed(0)}ms`);
|
2026-03-05 12:42:32 +01:00
|
|
|
|
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) => {
|
2026-03-05 16:44:17 +01:00
|
|
|
|
_crashDiag.log(`WEBGL_LOST: ${this.width}x${this.height}`);
|
2026-03-05 12:42:32 +01:00
|
|
|
|
console.error('[CRASH-DIAG] WebGL context LOST on canvas', this.width, 'x', this.height, '— prevented:', e.defaultPrevented);
|
|
|
|
|
|
});
|
|
|
|
|
|
this.addEventListener('webglcontextrestored', () => {
|
2026-03-05 16:44:17 +01:00
|
|
|
|
_crashDiag.log('WEBGL_RESTORED');
|
2026-03-05 12:42:32 +01:00
|
|
|
|
console.warn('[CRASH-DIAG] WebGL context restored');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return ctx;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-27 08:28:30 +01:00
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 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'],
|
2026-03-12 21:02:28 -04:00
|
|
|
|
[SSE_EVENTS.SESSION_TERMINAL, '_onSSETerminal'],
|
|
|
|
|
|
[SSE_EVENTS.SESSION_NEEDS_REFRESH, '_onSSENeedsRefresh'],
|
|
|
|
|
|
[SSE_EVENTS.SESSION_CLEAR_TERMINAL, '_onSSEClearTerminal'],
|
2026-03-03 22:40:18 +01:00
|
|
|
|
[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'],
|
2026-03-21 07:20:18 +01:00
|
|
|
|
|
|
|
|
|
|
// Orchestrator loop
|
|
|
|
|
|
[SSE_EVENTS.ORCHESTRATOR_STATE_CHANGED, '_onOrchestratorStateChanged'],
|
2026-03-21 20:03:24 +01:00
|
|
|
|
[SSE_EVENTS.ORCHESTRATOR_PLAN_PROGRESS, '_onOrchestratorPlanProgress'],
|
2026-03-21 07:20:18 +01:00
|
|
|
|
[SSE_EVENTS.ORCHESTRATOR_PLAN_READY, '_onOrchestratorPlanReady'],
|
|
|
|
|
|
[SSE_EVENTS.ORCHESTRATOR_PHASE_STARTED, '_onOrchestratorPhaseStarted'],
|
|
|
|
|
|
[SSE_EVENTS.ORCHESTRATOR_PHASE_COMPLETED, '_onOrchestratorPhaseCompleted'],
|
|
|
|
|
|
[SSE_EVENTS.ORCHESTRATOR_PHASE_FAILED, '_onOrchestratorPhaseFailed'],
|
|
|
|
|
|
[SSE_EVENTS.ORCHESTRATOR_VERIFICATION, '_onOrchestratorVerification'],
|
|
|
|
|
|
[SSE_EVENTS.ORCHESTRATOR_TASK_ASSIGNED, '_onOrchestratorTaskAssigned'],
|
|
|
|
|
|
[SSE_EVENTS.ORCHESTRATOR_TASK_COMPLETED, '_onOrchestratorTaskCompleted'],
|
|
|
|
|
|
[SSE_EVENTS.ORCHESTRATOR_TASK_FAILED, '_onOrchestratorTaskFailed'],
|
|
|
|
|
|
[SSE_EVENTS.ORCHESTRATOR_COMPLETED, '_onOrchestratorCompleted'],
|
|
|
|
|
|
[SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'],
|
2026-04-12 10:07:15 -04:00
|
|
|
|
|
|
|
|
|
|
// Clipboard
|
|
|
|
|
|
[SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'],
|
2026-03-03 22:40:18 +01:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-04-11 06:59:50 +02:00
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Session Name Prefix Parser
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Parses w<N>-<caseName> or s<N>-<caseName> prefix from session names.
|
|
|
|
|
|
// Returns { prefix, suffix } or null if name does not match the pattern.
|
|
|
|
|
|
function parseSessionPrefix(name) {
|
|
|
|
|
|
if (!name) return null;
|
|
|
|
|
|
const m = name.match(/^(w\d+-[a-zA-Z0-9_-]+|s\d+-[a-zA-Z0-9_-]+)/);
|
|
|
|
|
|
if (!m) return null;
|
|
|
|
|
|
const prefix = m[1];
|
|
|
|
|
|
const rest = name.slice(prefix.length);
|
|
|
|
|
|
if (rest === "") return { prefix, suffix: "" };
|
|
|
|
|
|
if (rest.startsWith(": ")) return { prefix, suffix: rest.slice(2) };
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// CodemanApp Class — constructor and global state
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-02-26 16:44:34 +01:00
|
|
|
|
class CodemanApp {
|
2026-01-18 05:14:34 +01:00
|
|
|
|
constructor() {
|
|
|
|
|
|
this.sessions = new Map();
|
2026-03-03 22:56:40 +01:00
|
|
|
|
this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results
|
2026-01-29 12:47:23 +01:00
|
|
|
|
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
|
|
|
|
|
|
this.draggedTabId = null; // Currently dragged tab session ID
|
2026-01-18 07:31:19 +01:00
|
|
|
|
this.cases = [];
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this.currentRun = null;
|
2026-01-19 01:26:38 +01:00
|
|
|
|
this.totalTokens = 0;
|
2026-01-25 07:26:49 +01:00
|
|
|
|
this.globalStats = null; // Global token/cost stats across all sessions
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this.eventSource = null;
|
2026-05-16 23:56:53 -04:00
|
|
|
|
// Stable per-page client ID — lets the server target this connection
|
|
|
|
|
|
// for live filter updates (POST /api/events/subscribe) without forcing
|
|
|
|
|
|
// an SSE reconnect on session switches.
|
|
|
|
|
|
this._clientId = (typeof crypto !== 'undefined' && crypto.randomUUID)
|
|
|
|
|
|
? crypto.randomUUID()
|
|
|
|
|
|
: 'c-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
2026-01-18 07:31:19 +01:00
|
|
|
|
this.terminal = null;
|
|
|
|
|
|
this.fitAddon = null;
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.activeSessionId = null;
|
2026-02-13 05:07:51 +01:00
|
|
|
|
this._initGeneration = 0; // dedup concurrent handleInit calls
|
2026-02-18 11:28:34 +01:00
|
|
|
|
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
2026-02-13 05:07:51 +01:00
|
|
|
|
this._selectGeneration = 0; // cancel stale selectSession loads
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.respawnStatus = {};
|
|
|
|
|
|
this.respawnTimers = {}; // Track timed respawn timers
|
2026-01-25 03:57:44 +01:00
|
|
|
|
this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } }
|
|
|
|
|
|
this.respawnActionLogs = {}; // { sessionId: [action, action, ...] } (max 20)
|
|
|
|
|
|
this.timerCountdownInterval = null; // Interval for updating countdown display
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.terminalBuffers = new Map(); // Store terminal content per session
|
2026-01-19 01:26:38 +01:00
|
|
|
|
this.editingSessionId = null; // Session being edited in options modal
|
2026-01-19 02:10:44 +01:00
|
|
|
|
this.pendingCloseSessionId = null; // Session pending close confirmation
|
2026-02-18 12:51:35 +01:00
|
|
|
|
this.muxSessions = []; // Screen sessions for process monitor
|
2026-01-18 05:14:34 +01:00
|
|
|
|
|
2026-01-21 22:36:45 +01:00
|
|
|
|
// Ralph loop/todo state per session
|
|
|
|
|
|
this.ralphStates = new Map(); // Map<sessionId, { loop, todos }>
|
2026-01-25 14:33:12 +01:00
|
|
|
|
|
|
|
|
|
|
// Subagent (Claude Code background agent) tracking
|
|
|
|
|
|
this.subagents = new Map(); // Map<agentId, SubagentInfo>
|
|
|
|
|
|
this.subagentActivity = new Map(); // Map<agentId, activity[]> - recent tool calls/progress
|
2026-01-26 10:42:04 +01:00
|
|
|
|
this.subagentToolResults = new Map(); // Map<agentId, Map<toolUseId, result>> - tool results by toolUseId
|
2026-01-25 14:33:12 +01:00
|
|
|
|
this.activeSubagentId = null; // Currently selected subagent for detail view
|
|
|
|
|
|
this.subagentPanelVisible = false;
|
|
|
|
|
|
this.subagentWindows = new Map(); // Map<agentId, { element, position }>
|
2026-02-12 18:26:16 +01:00
|
|
|
|
this.subagentWindowZIndex = ZINDEX_SUBAGENT_BASE;
|
2026-01-26 02:21:34 +01:00
|
|
|
|
this.minimizedSubagents = new Map(); // Map<sessionId, Set<agentId>> - minimized to tab
|
2026-01-26 19:42:01 +01:00
|
|
|
|
this._subagentHideTimeout = null; // Timeout for hover-based dropdown hide
|
2026-01-29 14:56:11 +01:00
|
|
|
|
|
|
|
|
|
|
// 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();
|
2026-02-12 08:34:51 +01:00
|
|
|
|
|
2026-02-13 05:07:51 +01:00
|
|
|
|
// 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 }>
|
|
|
|
|
|
|
2026-02-13 21:07:49 +01:00
|
|
|
|
this.terminalBufferCache = new Map(); // Map<sessionId, string> — client-side cache for instant tab re-visits (max 20)
|
2026-02-21 23:37:29 +01:00
|
|
|
|
|
2026-01-21 22:36:45 +01:00
|
|
|
|
this.ralphStatePanelCollapsed = true; // Default to collapsed
|
2026-01-30 17:50:02 +01:00
|
|
|
|
this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel
|
2026-01-19 13:21:01 +01:00
|
|
|
|
|
2026-01-29 13:22:02 +01:00
|
|
|
|
// Plan subagent windows (visible agents during plan generation)
|
2026-01-28 04:04:40 +01:00
|
|
|
|
this.planSubagents = new Map(); // Map<agentId, { type, model, status, startTime, element, relativePos }>
|
2026-02-12 18:26:16 +01:00
|
|
|
|
this.planSubagentWindowZIndex = ZINDEX_PLAN_SUBAGENT_BASE;
|
2026-01-27 23:15:25 +01:00
|
|
|
|
this.planGenerationStopped = false; // Flag to ignore SSE events after Stop
|
2026-01-28 04:04:40 +01:00
|
|
|
|
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
|
2026-01-27 23:15:25 +01:00
|
|
|
|
|
2026-01-26 00:15:11 +01:00
|
|
|
|
// Project Insights tracking (active Bash tools with clickable file paths)
|
|
|
|
|
|
this.projectInsights = new Map(); // Map<sessionId, ActiveBashTool[]>
|
2026-01-25 23:41:59 +01:00
|
|
|
|
this.logViewerWindows = new Map(); // Map<windowId, { element, eventSource, filePath }>
|
2026-02-12 18:26:16 +01:00
|
|
|
|
this.logViewerWindowZIndex = ZINDEX_LOG_VIEWER_BASE;
|
2026-01-26 00:15:11 +01:00
|
|
|
|
this.projectInsightsPanelVisible = false;
|
2026-03-21 07:20:18 +01:00
|
|
|
|
|
|
|
|
|
|
// Orchestrator loop state
|
|
|
|
|
|
this.orchestratorState = null; // { state, plan, currentPhaseIndex, stats }
|
|
|
|
|
|
this.orchestratorPanelVisible = false;
|
2026-01-26 02:21:34 +01:00
|
|
|
|
this.currentSessionWorkingDir = null; // Track current session's working dir for path normalization
|
2026-01-25 23:41:59 +01:00
|
|
|
|
|
2026-01-28 02:31:58 +01:00
|
|
|
|
// Image popup windows (auto-open for detected screenshots/images)
|
|
|
|
|
|
this.imagePopups = new Map(); // Map<imageId, { element, sessionId, filePath }>
|
2026-02-12 18:26:16 +01:00
|
|
|
|
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
|
2026-01-28 02:31:58 +01:00
|
|
|
|
|
2026-03-14 21:24:22 +01:00
|
|
|
|
// File browser state (methods in panels-ui.js)
|
|
|
|
|
|
this.fileBrowserData = null;
|
|
|
|
|
|
this.fileBrowserExpandedDirs = new Set();
|
|
|
|
|
|
this.fileBrowserFilter = '';
|
|
|
|
|
|
this.fileBrowserAllExpanded = false;
|
|
|
|
|
|
this.fileBrowserDragListeners = null;
|
|
|
|
|
|
this.filePreviewContent = '';
|
|
|
|
|
|
|
|
|
|
|
|
// Toast container cache (methods in panels-ui.js)
|
|
|
|
|
|
this._toastContainer = null;
|
|
|
|
|
|
|
2026-03-08 16:06:15 +01:00
|
|
|
|
// Tunnel indicator state
|
|
|
|
|
|
this._tunnelUrl = null;
|
|
|
|
|
|
|
2026-01-24 03:49:19 +01:00
|
|
|
|
// Tab alert states: Map<sessionId, 'action' | 'idle'>
|
|
|
|
|
|
this.tabAlerts = new Map();
|
|
|
|
|
|
|
2026-01-26 23:00:47 +01:00
|
|
|
|
// 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();
|
|
|
|
|
|
|
2026-03-12 21:02:28 -04:00
|
|
|
|
// WebSocket terminal I/O (low-latency bypass of HTTP POST + SSE)
|
|
|
|
|
|
this._ws = null; // WebSocket instance for active session
|
|
|
|
|
|
this._wsSessionId = null; // Session ID the WS is connected to
|
|
|
|
|
|
this._wsReady = false; // True when WS is open and ready for I/O
|
|
|
|
|
|
|
2026-01-25 22:09:45 +01:00
|
|
|
|
// Terminal write batching with DEC 2026 sync support
|
2026-02-12 20:07:36 +01:00
|
|
|
|
this.pendingWrites = [];
|
2026-01-18 21:36:37 +01:00
|
|
|
|
this.writeFrameScheduled = false;
|
2026-01-25 08:30:42 +01:00
|
|
|
|
this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll
|
2026-01-25 22:09:45 +01:00
|
|
|
|
this.syncWaitTimeout = null; // Timeout for incomplete sync blocks
|
2026-02-18 21:53:34 +01:00
|
|
|
|
this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes
|
|
|
|
|
|
this._loadBufferQueue = null; // queued SSE events during buffer load
|
2026-01-18 21:36:37 +01:00
|
|
|
|
|
2026-01-30 17:50:02 +01:00
|
|
|
|
// Flicker filter state (buffers output after screen clears)
|
|
|
|
|
|
this.flickerFilterBuffer = '';
|
|
|
|
|
|
this.flickerFilterActive = false;
|
|
|
|
|
|
this.flickerFilterTimeout = null;
|
|
|
|
|
|
|
2026-03-22 23:55:39 +01:00
|
|
|
|
// Render debounce timers (managed by _debouncedCall)
|
|
|
|
|
|
this._debounceTimers = Object.create(null);
|
2026-01-19 06:51:25 +01:00
|
|
|
|
|
|
|
|
|
|
// System stats polling
|
|
|
|
|
|
this.systemStatsInterval = null;
|
|
|
|
|
|
|
2026-01-27 17:46:43 +01:00
|
|
|
|
// SSE reconnect timeout (to prevent orphaned timeouts)
|
|
|
|
|
|
this.sseReconnectTimeout = null;
|
|
|
|
|
|
|
2026-01-27 20:24:00 +01:00
|
|
|
|
// SSE event listener cleanup function (to prevent listener accumulation on reconnect)
|
|
|
|
|
|
this._sseListenerCleanup = null;
|
|
|
|
|
|
|
2026-01-30 13:25:18 +01:00
|
|
|
|
// SSE connection status tracking
|
|
|
|
|
|
this.reconnectAttempts = 0;
|
|
|
|
|
|
this.maxReconnectAttempts = 10;
|
|
|
|
|
|
this.isOnline = navigator.onLine;
|
|
|
|
|
|
|
2026-02-12 08:34:51 +01:00
|
|
|
|
// Offline input queue
|
|
|
|
|
|
this._inputQueue = new Map(); // Map<sessionId, string>
|
|
|
|
|
|
this._inputQueueMaxBytes = 64 * 1024; // 64KB cap per session
|
|
|
|
|
|
this._connectionStatus = 'connected';
|
|
|
|
|
|
|
2026-02-20 14:04:16 +01:00
|
|
|
|
// Sequential input send chain — ensures keystroke ordering across async fetches
|
|
|
|
|
|
this._inputSendChain = Promise.resolve();
|
2026-02-13 06:02:12 +01:00
|
|
|
|
|
2026-02-18 21:53:34 +01:00
|
|
|
|
// 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
|
2026-02-22 02:49:19 +01:00
|
|
|
|
this._restoringFlushedState = false; // true during selectSession buffer load — protects flushed Maps
|
2026-02-13 09:18:54 +01:00
|
|
|
|
|
2026-01-30 13:25:18 +01:00
|
|
|
|
// Accessibility: Focus trap for modals
|
|
|
|
|
|
this.activeFocusTrap = null;
|
|
|
|
|
|
|
2026-01-24 01:22:53 +01:00
|
|
|
|
// Notification system
|
|
|
|
|
|
this.notificationManager = new NotificationManager(this);
|
|
|
|
|
|
this.idleTimers = new Map(); // Map<sessionId, timeout> for stuck detection
|
|
|
|
|
|
|
2026-01-19 11:48:02 +01:00
|
|
|
|
// DOM element cache for performance (avoid repeated getElementById calls)
|
|
|
|
|
|
this._elemCache = {};
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this.init();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 11:48:02 +01:00
|
|
|
|
// Cached element getter - avoids repeated DOM queries
|
|
|
|
|
|
$(id) {
|
|
|
|
|
|
if (!this._elemCache[id]) {
|
|
|
|
|
|
this._elemCache[id] = document.getElementById(id);
|
|
|
|
|
|
}
|
|
|
|
|
|
return this._elemCache[id];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 23:32:28 +01:00
|
|
|
|
// Clear a named timeout property: if (this[name]) { clearTimeout(this[name]); this[name] = null; }
|
|
|
|
|
|
_clearTimer(timerName) {
|
|
|
|
|
|
if (this[timerName]) {
|
|
|
|
|
|
clearTimeout(this[timerName]);
|
|
|
|
|
|
this[timerName] = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if a selectSession generation is stale (a newer tab switch has started).
|
|
|
|
|
|
// If stale, cleans up buffer-loading state and returns true.
|
|
|
|
|
|
_isStaleSelect(selectGen) {
|
|
|
|
|
|
if (selectGen !== this._selectGeneration) {
|
|
|
|
|
|
if (this._isLoadingBuffer) this._finishBufferLoad();
|
|
|
|
|
|
this._restoringFlushedState = false;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-25 20:12:16 +01:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-25 20:16:46 +01:00
|
|
|
|
// Estimate cost from tokens using Claude Opus pricing
|
|
|
|
|
|
// Input: $15/M tokens, Output: $75/M tokens
|
2026-01-25 20:12:16 +01:00
|
|
|
|
estimateCost(inputTokens, outputTokens) {
|
2026-01-25 20:16:46 +01:00
|
|
|
|
const inputCost = (inputTokens / 1000000) * 15;
|
|
|
|
|
|
const outputCost = (outputTokens / 1000000) * 75;
|
2026-01-25 20:12:16 +01:00
|
|
|
|
return inputCost + outputCost;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Pending Hooks State Machine
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
2026-01-26 23:00:47 +01:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Init — app bootstrap and mobile setup
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
init() {
|
2026-01-30 22:25:59 +01:00
|
|
|
|
// Initialize mobile detection first (adds device classes to body)
|
|
|
|
|
|
MobileDetection.init();
|
2026-01-31 14:46:26 +01:00
|
|
|
|
// Initialize mobile handlers
|
2026-01-31 07:51:29 +01:00
|
|
|
|
KeyboardHandler.init();
|
2026-01-31 14:46:26 +01:00
|
|
|
|
SwipeHandler.init();
|
2026-02-25 00:32:45 +01:00
|
|
|
|
VoiceInput.init();
|
2026-02-25 08:19:47 +01:00
|
|
|
|
KeyboardAccessoryBar.init();
|
2026-04-03 04:17:33 +02:00
|
|
|
|
// Apply keyboard bar mode from settings
|
|
|
|
|
|
const _kbSettings = this.loadAppSettingsFromStorage();
|
|
|
|
|
|
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
2026-01-19 19:22:28 +01:00
|
|
|
|
this.applyHeaderVisibilitySettings();
|
2026-02-18 10:23:10 +01:00
|
|
|
|
this.applyTabWrapSettings();
|
2026-01-20 17:54:59 +01:00
|
|
|
|
this.applyMonitorVisibility();
|
2026-02-16 21:00:55 +01:00
|
|
|
|
// 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');
|
2026-02-18 21:53:34 +01:00
|
|
|
|
// 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.
|
2026-02-18 11:59:42 +01:00
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
|
this.initTerminal();
|
|
|
|
|
|
this.loadFontSize();
|
2026-02-18 21:53:34 +01:00
|
|
|
|
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);
|
2026-02-18 11:59:42 +01:00
|
|
|
|
});
|
2026-02-27 08:28:30 +01:00
|
|
|
|
// Register service worker for push notifications
|
|
|
|
|
|
this.registerServiceWorker();
|
2026-03-08 16:06:15 +01:00
|
|
|
|
// Fetch tunnel status for header indicator (desktop only)
|
|
|
|
|
|
this.loadTunnelStatus();
|
2026-02-18 11:28:34 +01:00
|
|
|
|
// 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);
|
2026-02-26 12:04:57 +01:00
|
|
|
|
this._initRunMode();
|
2026-01-18 07:31:19 +01:00
|
|
|
|
this.setupEventListeners();
|
2026-02-25 17:14:53 +01:00
|
|
|
|
// 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'));
|
2026-02-16 16:45:47 +01:00
|
|
|
|
}
|
2026-02-18 11:28:34 +01:00
|
|
|
|
// System stats polling deferred until sessions exist (started in handleInit/session:created)
|
2026-01-30 13:25:18 +01:00
|
|
|
|
// Setup online/offline detection
|
|
|
|
|
|
this.setupOnlineDetection();
|
2026-01-24 02:19:46 +01:00
|
|
|
|
// Load server-stored settings (async, re-applies visibility after load)
|
2026-02-18 11:28:34 +01:00
|
|
|
|
this.loadAppSettingsFromServer(settingsPromise).then(() => {
|
2026-01-24 02:19:46 +01:00
|
|
|
|
this.applyHeaderVisibilitySettings();
|
2026-02-18 10:23:10 +01:00
|
|
|
|
this.applyTabWrapSettings();
|
2026-01-24 02:19:46 +01:00
|
|
|
|
this.applyMonitorVisibility();
|
|
|
|
|
|
});
|
2026-02-18 11:59:42 +01:00
|
|
|
|
// Hide loading skeleton now that the app shell is ready
|
|
|
|
|
|
document.body.classList.add('app-loaded');
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 19:20:44 +01:00
|
|
|
|
_initWebGL() {
|
|
|
|
|
|
if (typeof WebglAddon === 'undefined') return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
this._webglAddon = new WebglAddon.WebglAddon();
|
|
|
|
|
|
this._webglAddon.onContextLoss(() => {
|
|
|
|
|
|
console.error('[CRASH-DIAG] WebGL context LOST — falling back to canvas renderer');
|
2026-05-16 23:56:30 -04:00
|
|
|
|
_crashDiag.log('WEBGL_LOST');
|
|
|
|
|
|
this._disableWebGLSticky('context-lost');
|
2026-05-19 11:43:36 +02:00
|
|
|
|
this._disposeWebGLObserver();
|
2026-05-16 23:56:30 -04:00
|
|
|
|
this._webglAddon?.dispose();
|
2026-03-14 19:20:44 +01:00
|
|
|
|
this._webglAddon = null;
|
|
|
|
|
|
});
|
|
|
|
|
|
this.terminal.loadAddon(this._webglAddon);
|
|
|
|
|
|
console.log('[CRASH-DIAG] WebGL renderer enabled');
|
2026-05-16 23:56:30 -04:00
|
|
|
|
this._installWebGLLongTaskGuard();
|
2026-03-14 19:20:44 +01:00
|
|
|
|
} catch (_e) { /* WebGL2 unavailable — canvas renderer used */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 23:56:30 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* Watch for sustained main-thread stalls that indicate WebGL/GPU trouble.
|
2026-05-19 11:43:36 +02:00
|
|
|
|
* After WEBGL_FALLBACK.LONGTASK_COUNT long tasks (>=LONGTASK_MS each) within
|
|
|
|
|
|
* WINDOW_MS, dispose the WebGL addon and persist a sticky disable so
|
|
|
|
|
|
* subsequent reloads also use the DOM renderer. GRACE_MS skips initial-load
|
|
|
|
|
|
* stalls. Force-re-enable: ?webgl=force.
|
2026-05-16 23:56:30 -04:00
|
|
|
|
*/
|
|
|
|
|
|
_installWebGLLongTaskGuard() {
|
|
|
|
|
|
if (typeof PerformanceObserver === 'undefined' || this._webglLongTaskObserver) return;
|
|
|
|
|
|
const installedAt = performance.now();
|
|
|
|
|
|
const recent = [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
this._webglLongTaskObserver = new PerformanceObserver((list) => {
|
|
|
|
|
|
if (!this._webglAddon) return;
|
|
|
|
|
|
const now = performance.now();
|
2026-05-19 11:43:36 +02:00
|
|
|
|
if (now - installedAt < WEBGL_FALLBACK.GRACE_MS) return;
|
|
|
|
|
|
if (evaluateWebGLLongTaskTrip(recent, list.getEntries(), now)) {
|
|
|
|
|
|
console.warn(`[CRASH-DIAG] WebGL long-task threshold (${recent.length} stalls/${WEBGL_FALLBACK.WINDOW_MS}ms) — falling back to canvas renderer`);
|
2026-05-16 23:56:30 -04:00
|
|
|
|
_crashDiag.log(`WEBGL_FALLBACK: ${recent.length}`);
|
|
|
|
|
|
this._disableWebGLSticky('long-tasks');
|
2026-05-19 11:43:36 +02:00
|
|
|
|
this._disposeWebGLObserver();
|
2026-05-16 23:56:30 -04:00
|
|
|
|
this._webglAddon?.dispose();
|
|
|
|
|
|
this._webglAddon = null;
|
|
|
|
|
|
try { this.terminal.refresh(0, this.terminal.rows - 1); } catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
this._webglLongTaskObserver.observe({ type: 'longtask', buffered: false });
|
|
|
|
|
|
} catch { /* longtask not supported */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 11:43:36 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Disconnect the WebGL longtask observer. Idempotent. Called from the trip
|
|
|
|
|
|
* path, the onContextLoss handler, and any future terminal-teardown path —
|
|
|
|
|
|
* the observer outlives its addon otherwise, holding a closure reference
|
|
|
|
|
|
* over `this` for every long task the page emits.
|
|
|
|
|
|
*/
|
|
|
|
|
|
_disposeWebGLObserver() {
|
|
|
|
|
|
if (!this._webglLongTaskObserver) return;
|
|
|
|
|
|
try { this._webglLongTaskObserver.disconnect(); } catch {}
|
|
|
|
|
|
this._webglLongTaskObserver = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 23:56:30 -04:00
|
|
|
|
_disableWebGLSticky(reason) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
localStorage.setItem('codeman-webgl-disabled', JSON.stringify({ reason, at: Date.now() }));
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Event Listeners (Keyboard Shortcuts, Resize, Beforeunload)
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-01-18 07:31:19 +01:00
|
|
|
|
setupEventListeners() {
|
2026-03-25 23:32:28 +01:00
|
|
|
|
// Keyboard shortcut lookup table — data-driven to avoid 12 separate if-blocks.
|
|
|
|
|
|
// Each entry: { key, altKey? (alternative key match), ctrl? (require Ctrl/Cmd),
|
|
|
|
|
|
// shift? (require Shift), action }.
|
|
|
|
|
|
const SHORTCUTS = [
|
|
|
|
|
|
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
|
|
|
|
|
|
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
|
|
|
|
|
|
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
|
|
|
|
|
|
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
|
|
|
|
|
|
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
|
|
|
|
|
|
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
|
|
|
|
|
|
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
|
|
|
|
|
|
{ key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() },
|
2026-04-12 10:02:12 -04:00
|
|
|
|
{ key: '{', ctrl: true, shift: true, action: () => this.moveActiveTabLeft() },
|
|
|
|
|
|
{ key: '}', ctrl: true, shift: true, action: () => this.moveActiveTabRight() },
|
2026-03-25 23:32:28 +01:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-01-19 01:42:47 +01:00
|
|
|
|
// Use capture to handle before terminal
|
2026-01-18 21:42:57 +01:00
|
|
|
|
document.addEventListener('keydown', (e) => {
|
2026-03-14 16:23:25 +09:00
|
|
|
|
// Don't intercept keys during CJK IME composition
|
|
|
|
|
|
if (e.isComposing || e.keyCode === 229) return;
|
|
|
|
|
|
|
2026-03-25 23:32:28 +01:00
|
|
|
|
// Escape - close panels and modals (different logic: no preventDefault, no return)
|
2026-01-18 21:42:57 +01:00
|
|
|
|
if (e.key === 'Escape') {
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.closeAllPanels();
|
2026-01-18 22:18:17 +01:00
|
|
|
|
this.closeHelp();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 06:59:50 +02:00
|
|
|
|
// Alt+1-9: switch to Codeman session by index
|
|
|
|
|
|
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '9') {
|
|
|
|
|
|
const idx = parseInt(e.key) - 1;
|
|
|
|
|
|
if (idx < this.sessionOrder.length) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.selectSession(this.sessionOrder[idx]);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 23:32:28 +01:00
|
|
|
|
// Match against shortcut table
|
|
|
|
|
|
for (const s of SHORTCUTS) {
|
|
|
|
|
|
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
|
|
|
|
|
|
const ctrlMatch = s.ctrl ? (e.ctrlKey || e.metaKey) : true;
|
|
|
|
|
|
const shiftMatch = s.shift ? e.shiftKey : !e.shiftKey;
|
|
|
|
|
|
if (keyMatch && ctrlMatch && shiftMatch) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
s.action();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-25 00:32:45 +01:00
|
|
|
|
}
|
2026-01-19 01:42:47 +01:00
|
|
|
|
}, true); // Use capture phase to handle before terminal
|
2026-01-25 20:36:04 +01:00
|
|
|
|
|
2026-02-04 07:24:32 +01:00
|
|
|
|
// Token stats click handler (with guard to prevent duplicate handlers on reconnect)
|
2026-01-25 20:36:04 +01:00
|
|
|
|
const tokenEl = this.$('headerTokens');
|
2026-02-04 07:24:32 +01:00
|
|
|
|
if (tokenEl && !tokenEl._statsHandlerAttached) {
|
2026-01-25 20:36:04 +01:00
|
|
|
|
tokenEl.classList.add('clickable');
|
2026-02-04 07:24:32 +01:00
|
|
|
|
tokenEl._statsHandlerAttached = true;
|
2026-01-25 20:36:04 +01:00
|
|
|
|
tokenEl.addEventListener('click', () => this.openTokenStats());
|
|
|
|
|
|
}
|
2026-01-30 13:25:18 +01:00
|
|
|
|
|
|
|
|
|
|
// Color picker for session customization
|
|
|
|
|
|
this.setupColorPicker();
|
2026-01-18 07:31:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// SSE Connection
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
2026-01-18 07:31:19 +01:00
|
|
|
|
|
2026-05-16 23:56:53 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* POST a live subscription update so the server filters terminal events
|
|
|
|
|
|
* to the given session(s) for this client. Fire-and-forget — failures
|
|
|
|
|
|
* are non-fatal because we'll still get every event we don't want
|
|
|
|
|
|
* (just at higher cost), and the next reconnect carries the filter via
|
|
|
|
|
|
* the SSE query string.
|
|
|
|
|
|
*/
|
|
|
|
|
|
_updateSseSubscription(sessionId) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const body = JSON.stringify({
|
|
|
|
|
|
clientId: this._clientId,
|
|
|
|
|
|
sessions: sessionId ? [sessionId] : null,
|
|
|
|
|
|
});
|
|
|
|
|
|
fetch('/api/events/subscribe', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body,
|
|
|
|
|
|
keepalive: true,
|
|
|
|
|
|
}).catch(() => { /* non-fatal */ });
|
|
|
|
|
|
} catch { /* non-fatal */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
connectSSE() {
|
2026-01-30 13:25:18 +01:00
|
|
|
|
// Check if browser is offline
|
|
|
|
|
|
if (!navigator.onLine) {
|
|
|
|
|
|
this.setConnectionStatus('offline');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-27 17:46:43 +01:00
|
|
|
|
// Clear any pending reconnect timeout to prevent duplicate connections
|
2026-03-25 23:32:28 +01:00
|
|
|
|
this._clearTimer('sseReconnectTimeout');
|
2026-01-27 17:46:43 +01:00
|
|
|
|
|
2026-01-27 20:24:00 +01:00
|
|
|
|
// Clean up existing SSE listeners before creating new connection (prevents listener accumulation)
|
|
|
|
|
|
if (this._sseListenerCleanup) {
|
|
|
|
|
|
this._sseListenerCleanup();
|
|
|
|
|
|
this._sseListenerCleanup = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-26 12:35:00 +01:00
|
|
|
|
// Close existing EventSource before creating new one to prevent duplicate connections
|
|
|
|
|
|
if (this.eventSource) {
|
|
|
|
|
|
this.eventSource.close();
|
|
|
|
|
|
this.eventSource = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-30 13:25:18 +01:00
|
|
|
|
// Show connecting state
|
|
|
|
|
|
if (this.reconnectAttempts === 0) {
|
|
|
|
|
|
this.setConnectionStatus('connecting');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.setConnectionStatus('reconnecting');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 23:56:53 -04:00
|
|
|
|
// Build URL with stable client ID and (if known) the active-session
|
|
|
|
|
|
// filter so the server only streams session:terminal events for the
|
|
|
|
|
|
// session we're rendering. Lifecycle/metadata events are sent globally
|
|
|
|
|
|
// regardless of filter (server side).
|
|
|
|
|
|
const _sseParams = new URLSearchParams({ clientId: this._clientId });
|
|
|
|
|
|
if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId);
|
|
|
|
|
|
this.eventSource = new EventSource(`/api/events?${_sseParams.toString()}`);
|
2026-01-18 05:14:34 +01:00
|
|
|
|
|
2026-01-27 20:24:00 +01:00
|
|
|
|
// Store all event listeners for cleanup on reconnect
|
|
|
|
|
|
const listeners = [];
|
|
|
|
|
|
const addListener = (event, handler) => {
|
2026-01-30 14:14:45 +01:00
|
|
|
|
this.eventSource.addEventListener(event, handler);
|
|
|
|
|
|
listeners.push({ event, handler });
|
2026-01-27 20:24:00 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-30 13:25:18 +01:00
|
|
|
|
this.eventSource.onopen = () => {
|
|
|
|
|
|
this.reconnectAttempts = 0;
|
|
|
|
|
|
this.setConnectionStatus('connected');
|
|
|
|
|
|
};
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this.eventSource.onerror = () => {
|
2026-01-30 13:25:18 +01:00
|
|
|
|
this.reconnectAttempts++;
|
|
|
|
|
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
|
|
|
|
this.setConnectionStatus('disconnected');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.setConnectionStatus('reconnecting');
|
|
|
|
|
|
}
|
2026-01-26 12:35:00 +01:00
|
|
|
|
// Close the failed connection before scheduling reconnect
|
|
|
|
|
|
if (this.eventSource) {
|
|
|
|
|
|
this.eventSource.close();
|
|
|
|
|
|
this.eventSource = null;
|
|
|
|
|
|
}
|
2026-01-27 17:46:43 +01:00
|
|
|
|
// Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts)
|
2026-03-25 23:32:28 +01:00
|
|
|
|
this._clearTimer('sseReconnectTimeout');
|
2026-02-19 23:03:21 +01:00
|
|
|
|
// 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);
|
2026-01-30 13:25:18 +01:00
|
|
|
|
this.sseReconnectTimeout = setTimeout(() => this.connectSSE(), delay);
|
2026-01-18 05:14:34 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-03 22:56:40 +01:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// Register all SSE event handlers via centralized map
|
2026-03-03 22:56:40 +01:00
|
|
|
|
for (const [event] of _SSE_HANDLER_MAP) {
|
|
|
|
|
|
addListener(event, this._sseHandlerWrappers.get(event));
|
2026-03-03 22:40:18 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-18 05:14:34 +01:00
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 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.
|
2026-01-18 05:14:34 +01:00
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
_onInit(data) {
|
2026-03-05 16:44:17 +01:00
|
|
|
|
_crashDiag.log(`INIT: ${data.sessions?.length || 0} sessions`);
|
2026-03-03 22:40:18 +01:00
|
|
|
|
this.handleInit(data);
|
|
|
|
|
|
}
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
_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();
|
|
|
|
|
|
}
|
2026-01-18 05:14:34 +01:00
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
_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();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-18 05:14:34 +01:00
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
_onSessionDeleted(data) {
|
2026-03-12 21:02:28 -04:00
|
|
|
|
if (this._wsSessionId === data.id) this._disconnectWs();
|
2026-03-03 22:40:18 +01:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 21:02:28 -04:00
|
|
|
|
// SSE wrappers — skip terminal events when WebSocket is delivering for this session.
|
|
|
|
|
|
// WS handler calls the underlying _onSession* methods directly.
|
|
|
|
|
|
_onSSETerminal(data) {
|
|
|
|
|
|
if (this._wsReady && this._wsSessionId === data.id) return;
|
|
|
|
|
|
this._onSessionTerminal(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
_onSSENeedsRefresh(data) {
|
|
|
|
|
|
if (this._wsReady && this._wsSessionId === data?.id) return;
|
|
|
|
|
|
this._onSessionNeedsRefresh(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
_onSSEClearTerminal(data) {
|
|
|
|
|
|
if (this._wsReady && this._wsSessionId === data?.id) return;
|
|
|
|
|
|
this._onSessionClearTerminal(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
_onSessionTerminal(data) {
|
|
|
|
|
|
if (data.id === this.activeSessionId) {
|
2026-03-05 16:44:17 +01:00
|
|
|
|
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
|
2026-03-06 17:10:25 +01:00
|
|
|
|
|
2026-03-07 07:00:18 +01:00
|
|
|
|
// Hard cap: track total bytes queued in render buffers (pendingWrites +
|
|
|
|
|
|
// flickerFilterBuffer). When rAF is throttled (tab
|
2026-03-06 17:10:25 +01:00
|
|
|
|
// backgrounded, GPU busy), data accumulates with no flush, reaching
|
2026-03-07 07:00:18 +01:00
|
|
|
|
// 889KB+ and freezing Chrome for minutes. Drop data beyond 128KB and
|
|
|
|
|
|
// schedule a buffer reload to recover the display once the burst subsides.
|
2026-03-06 17:10:25 +01:00
|
|
|
|
const queued = (this.pendingWrites?.reduce((s, w) => s + w.length, 0) || 0)
|
|
|
|
|
|
+ (this.flickerFilterBuffer?.length || 0);
|
2026-03-07 07:00:18 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-06 17:10:25 +01:00
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
this.batchTerminalWrite(data.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 19:07:02 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Response Viewer — native-scroll panel for reading full Claude responses
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-04-11 07:20:09 +02:00
|
|
|
|
/** Strip dangerous elements and attributes from HTML (XSS prevention) */
|
|
|
|
|
|
_sanitizeHtml(html) {
|
|
|
|
|
|
const tpl = document.createElement('template');
|
|
|
|
|
|
tpl.innerHTML = html;
|
|
|
|
|
|
const frag = tpl.content;
|
|
|
|
|
|
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
|
|
|
|
|
|
el.remove();
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const el of frag.querySelectorAll('*')) {
|
|
|
|
|
|
for (const attr of [...el.attributes]) {
|
|
|
|
|
|
const name = attr.name.toLowerCase();
|
|
|
|
|
|
if (name.startsWith('on')) {
|
|
|
|
|
|
el.removeAttribute(attr.name);
|
|
|
|
|
|
} else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) {
|
|
|
|
|
|
const val = attr.value.replace(/\s/g, '').toLowerCase();
|
|
|
|
|
|
if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) {
|
|
|
|
|
|
el.removeAttribute(attr.name);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.appendChild(frag);
|
|
|
|
|
|
return div.innerHTML;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-28 08:58:42 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Strip ANSI escape sequences and Claude CLI chrome (status bar, hints,
|
|
|
|
|
|
* spinner, progress bar) from a terminal buffer so the response viewer can
|
|
|
|
|
|
* show just the conversational text when the JSONL transcript is missing.
|
|
|
|
|
|
*/
|
|
|
|
|
|
_cleanTerminalBuffer(buf) {
|
|
|
|
|
|
const stripped = buf
|
|
|
|
|
|
// CSI sequences — params (0x30-0x3F includes digits, ?, ;, <, =, >),
|
|
|
|
|
|
// intermediates (0x20-0x2F), final byte (0x40-0x7E). Catches \x1b[>c,
|
|
|
|
|
|
// \x1b[>q, \x1b[?25l etc. that the previous regex missed.
|
|
|
|
|
|
.replace(/\x1b\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g, '')
|
|
|
|
|
|
// OSC sequences (window titles etc.) terminated by BEL or ST
|
|
|
|
|
|
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
|
|
|
|
// DCS / APC / PM / SOS sequences
|
|
|
|
|
|
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
|
|
|
|
|
|
// SS2/SS3 + charset selects + single-char escapes
|
|
|
|
|
|
.replace(/\x1b[NO()][A-Z0-9]?/g, '')
|
|
|
|
|
|
.replace(/\x1b[>=<78cDEHM]/g, '')
|
|
|
|
|
|
// Stray control chars (except \t \n)
|
|
|
|
|
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
|
|
|
|
.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
|
|
|
|
|
|
|
|
|
|
// Drop Claude CLI chrome lines that aren't part of the response.
|
|
|
|
|
|
const CHROME_PATTERNS = [
|
|
|
|
|
|
/^\s*❯\s*/, // shell prompt
|
|
|
|
|
|
/^\s*[⏵⏺⏸⏹]+\s*/, // status glyphs
|
|
|
|
|
|
/^\s*✻\s*(Crunching|Crunched|Thinking)/i, // spinner lines
|
|
|
|
|
|
/bypass permissions/i,
|
|
|
|
|
|
/\bshift\+tab to cycle\b/i,
|
|
|
|
|
|
/^\s*focus\s*$/,
|
|
|
|
|
|
/^\s*new task\?/i,
|
|
|
|
|
|
/\/clear to save/i,
|
|
|
|
|
|
/^\s*─{5,}\s*$/, // horizontal dividers
|
|
|
|
|
|
/\[(Opus|Sonnet|Haiku|GPT|Claude)[\s\S]*(tokens?|\$|¥|%|↑|↓)/i, // status bar
|
|
|
|
|
|
/^\s*\[\d+[km]?\/\d+[km]?\]/i, // token counter
|
|
|
|
|
|
/[█░▓▒]{3,}/, // progress bar
|
|
|
|
|
|
/^\s*\(.*\s*(tokens?|context).*\)\s*$/i,
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const lines = stripped.split('\n');
|
|
|
|
|
|
const kept = lines.filter((line) => {
|
|
|
|
|
|
const trimmed = line.trim();
|
|
|
|
|
|
if (!trimmed) return true; // keep blanks so paragraphs survive
|
|
|
|
|
|
return !CHROME_PATTERNS.some((re) => re.test(line));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return kept
|
|
|
|
|
|
.join('\n')
|
|
|
|
|
|
.replace(/[ \t]+$/gm, '')
|
|
|
|
|
|
.replace(/\n{4,}/g, '\n\n\n')
|
|
|
|
|
|
.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Wrap ASCII/box diagrams in fenced code blocks so marked.js preserves whitespace.
|
|
|
|
|
|
* Claude often emits box-drawing diagrams without triple-backticks; without this
|
|
|
|
|
|
* step, HTML collapses the whitespace and the diagram becomes unreadable prose.
|
|
|
|
|
|
*/
|
|
|
|
|
|
_preprocessAsciiArt(text) {
|
|
|
|
|
|
// Only trigger on characters that rarely appear in prose:
|
|
|
|
|
|
// U+2500-U+257F Box Drawing (─│┌┐└┘├┤┬┴┼╔╗╚╝═║)
|
|
|
|
|
|
// U+2580-U+259F Block Elements (▀▄█▌▐░▒▓, progress bars)
|
|
|
|
|
|
// Deliberately excluded:
|
|
|
|
|
|
// U+2190-U+21FF Arrows (→←↑↓⇒ — common rhetorical prose)
|
|
|
|
|
|
// U+25A0-U+25FF Geometric Shapes (●○■□◆◇ — common bullets)
|
|
|
|
|
|
// Triggering on those would wrap numbered lists / prose that merely uses
|
|
|
|
|
|
// arrows in code blocks and break their markdown rendering.
|
|
|
|
|
|
const BOX_PATTERN = /[─-╿▀-▟]/;
|
|
|
|
|
|
|
|
|
|
|
|
// Preserve existing fenced code blocks as-is (hide them behind placeholders)
|
|
|
|
|
|
const fenceRe = /```[\s\S]*?```/g;
|
|
|
|
|
|
const placeholders = [];
|
|
|
|
|
|
const masked = text.replace(fenceRe, (m) => {
|
|
|
|
|
|
placeholders.push(m);
|
|
|
|
|
|
return ` |