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-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-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');
|
|
|
|
|
|
this._webglAddon.dispose();
|
|
|
|
|
|
this._webglAddon = null;
|
|
|
|
|
|
});
|
|
|
|
|
|
this.terminal.loadAddon(this._webglAddon);
|
|
|
|
|
|
console.log('[CRASH-DIAG] WebGL renderer enabled');
|
|
|
|
|
|
} catch (_e) { /* WebGL2 unavailable — canvas renderer used */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-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-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-01-18 05:14:34 +01:00
|
|
|
|
this.eventSource = new EventSource('/api/events');
|
|
|
|
|
|
|
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-10 19:12:06 +08:00
|
|
|
|
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
|
|
|
|
|
|
_renderMarkdown(text) {
|
|
|
|
|
|
if (typeof marked !== 'undefined' && marked.parse) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return marked.parse(text, { breaks: true, gfm: true });
|
|
|
|
|
|
} catch { /* fall through */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
// Fallback: escape HTML and preserve whitespace
|
|
|
|
|
|
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
|
|
|
|
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 19:07:02 +08:00
|
|
|
|
async toggleResponseViewer() {
|
|
|
|
|
|
const viewer = document.getElementById('responseViewer');
|
|
|
|
|
|
const backdrop = document.getElementById('responseViewerBackdrop');
|
|
|
|
|
|
if (!viewer) return;
|
|
|
|
|
|
|
|
|
|
|
|
const isOpen = viewer.classList.contains('visible');
|
|
|
|
|
|
if (isOpen) {
|
|
|
|
|
|
viewer.classList.remove('visible');
|
|
|
|
|
|
backdrop.classList.remove('visible');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!this.activeSessionId) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Source 1: Transcript JSONL (best quality — clean structured text from Claude)
|
|
|
|
|
|
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response`);
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
let lastResponse = data.text || '';
|
|
|
|
|
|
|
|
|
|
|
|
// Source 2: Terminal buffer fallback (strip ANSI codes)
|
|
|
|
|
|
if (!lastResponse) {
|
|
|
|
|
|
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
|
|
|
|
|
|
const termData = await termRes.json();
|
|
|
|
|
|
if (termData.terminalBuffer) {
|
|
|
|
|
|
lastResponse = termData.terminalBuffer
|
|
|
|
|
|
.replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, '')
|
|
|
|
|
|
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
|
|
|
|
|
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
|
|
|
|
.replace(/\x1b[()][A-Z0-9]/g, '')
|
|
|
|
|
|
.replace(/\x1b[>=<]/g, '')
|
|
|
|
|
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
|
|
|
|
.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
|
|
|
|
.replace(/[ \t]+$/gm, '')
|
|
|
|
|
|
.replace(/\n{4,}/g, '\n\n\n')
|
|
|
|
|
|
.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const body = document.getElementById('responseViewerBody');
|
2026-04-10 19:12:06 +08:00
|
|
|
|
body.innerHTML = this._renderMarkdown(lastResponse);
|
2026-04-10 19:07:02 +08:00
|
|
|
|
|
|
|
|
|
|
// Reset state for fresh open
|
|
|
|
|
|
const title = document.getElementById('responseViewerTitle');
|
|
|
|
|
|
const moreBtn = document.getElementById('responseViewerMore');
|
|
|
|
|
|
if (title) title.textContent = 'Last Response';
|
|
|
|
|
|
if (moreBtn) { moreBtn.style.display = ''; moreBtn.textContent = 'More'; }
|
|
|
|
|
|
|
|
|
|
|
|
viewer.classList.add('visible');
|
|
|
|
|
|
backdrop.classList.add('visible');
|
|
|
|
|
|
body.scrollTop = 0;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Failed to load response:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async loadFullContext() {
|
|
|
|
|
|
if (!this.activeSessionId) return;
|
|
|
|
|
|
const moreBtn = document.getElementById('responseViewerMore');
|
|
|
|
|
|
if (moreBtn) moreBtn.textContent = '...';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response?context=full`);
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
const messages = data.messages || [];
|
|
|
|
|
|
const body = document.getElementById('responseViewerBody');
|
|
|
|
|
|
const title = document.getElementById('responseViewerTitle');
|
|
|
|
|
|
if (!body) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (messages.length === 0) {
|
|
|
|
|
|
body.textContent = 'No conversation history available';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Render conversation thread
|
|
|
|
|
|
body.innerHTML = '';
|
|
|
|
|
|
for (const msg of messages) {
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.className = 'rv-message';
|
|
|
|
|
|
|
|
|
|
|
|
const role = document.createElement('div');
|
|
|
|
|
|
role.className = 'rv-role ' + (msg.role === 'user' ? 'rv-role-user' : 'rv-role-assistant');
|
|
|
|
|
|
role.textContent = msg.role === 'user' ? 'You' : 'Claude';
|
|
|
|
|
|
div.appendChild(role);
|
|
|
|
|
|
|
|
|
|
|
|
const text = document.createElement('div');
|
|
|
|
|
|
text.className = 'rv-text';
|
2026-04-10 19:12:06 +08:00
|
|
|
|
text.innerHTML = this._renderMarkdown(msg.text);
|
2026-04-10 19:07:02 +08:00
|
|
|
|
div.appendChild(text);
|
|
|
|
|
|
|
|
|
|
|
|
body.appendChild(div);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (title) title.textContent = `Conversation (${messages.length} messages)`;
|
|
|
|
|
|
if (moreBtn) moreBtn.style.display = 'none';
|
|
|
|
|
|
// Scroll to bottom (latest message)
|
|
|
|
|
|
body.scrollTop = body.scrollHeight;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Failed to load context:', err);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (moreBtn) moreBtn.textContent = 'More';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
async _onSessionNeedsRefresh() {
|
2026-02-18 21:53:34 +01:00
|
|
|
|
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
|
|
|
|
|
// so reload the buffer to recover from any display corruption.
|
2026-03-03 22:40:18 +01:00
|
|
|
|
if (!this.activeSessionId || !this.terminal) return;
|
2026-03-24 09:22:06 +08:00
|
|
|
|
// Skip if buffer load already in progress — avoids competing clear+rewrite cycles
|
|
|
|
|
|
if (this._isLoadingBuffer) return;
|
2026-03-03 22:40:18 +01:00
|
|
|
|
try {
|
2026-03-07 07:00:18 +01:00
|
|
|
|
const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
2026-03-03 22:40:18 +01:00
|
|
|
|
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) {
|
2026-03-24 09:22:06 +08:00
|
|
|
|
// Skip if selectSession is already loading the buffer — clearTerminal arriving
|
|
|
|
|
|
// during buffer load would clear the terminal mid-write, causing visible flicker
|
|
|
|
|
|
// and a race between two concurrent chunkedTerminalWrite calls (especially on mobile
|
|
|
|
|
|
// where rAF is slower). selectSession will handle the final buffer state.
|
|
|
|
|
|
if (this._isLoadingBuffer) return;
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
|
2026-02-18 21:53:34 +01:00
|
|
|
|
try {
|
2026-03-03 22:40:18 +01:00
|
|
|
|
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);
|
2026-02-18 21:53:34 +01:00
|
|
|
|
}
|
2026-03-03 22:40:18 +01:00
|
|
|
|
|
|
|
|
|
|
// 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();
|
2026-02-18 21:53:34 +01:00
|
|
|
|
} catch (err) {
|
2026-03-03 22:40:18 +01:00
|
|
|
|
console.error('clearTerminal refresh failed:', err);
|
2026-02-18 21:53:34 +01:00
|
|
|
|
}
|
2026-03-03 22:40:18 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_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`);
|
|
|
|
|
|
}
|
2026-03-26 22:50:25 +01:00
|
|
|
|
this._notifySession(data.id, 'critical', 'session-error', 'Session Error', data.error || 'Unknown error');
|
2026-03-03 22:40:18 +01:00
|
|
|
|
}
|
2026-02-18 21:53:34 +01:00
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
_onSessionExit(data) {
|
2026-03-12 21:02:28 -04:00
|
|
|
|
if (this._wsSessionId === data.id) this._disconnectWs();
|
2026-03-03 22:40:18 +01:00
|
|
|
|
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) {
|
2026-03-26 22:50:25 +01:00
|
|
|
|
this._notifySession(data.id, 'critical', 'session-crash', 'Session Crashed', `Exited with code ${data.code}`);
|
2026-03-03 22:40:18 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-18 07:31:19 +01:00
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
_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(() => {
|
2026-03-26 22:50:25 +01:00
|
|
|
|
this._notifySession(data.id, 'warning', 'session-stuck', 'Session Idle', `Idle for ${Math.round(threshold / 60000)}+ minutes`);
|
2026-01-24 01:22:53 +01:00
|
|
|
|
this.idleTimers.delete(data.id);
|
2026-03-03 22:40:18 +01:00
|
|
|
|
}, 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);
|
2026-01-24 01:22:53 +01:00
|
|
|
|
}
|
2026-03-03 22:40:18 +01:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
_onSessionAutoClear(data) {
|
|
|
|
|
|
if (data.sessionId === this.activeSessionId) {
|
|
|
|
|
|
this.showToast(`Auto-cleared at ${data.tokens.toLocaleString()} tokens`, 'info');
|
|
|
|
|
|
this.updateRespawnTokens(0);
|
|
|
|
|
|
}
|
2026-03-26 22:50:25 +01:00
|
|
|
|
this._notifySession(data.sessionId, 'info', 'auto-clear', 'Auto-Cleared', `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`);
|
2026-03-03 22:40:18 +01:00
|
|
|
|
}
|
2026-01-18 05:14:34 +01:00
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
_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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Connection Status, Input Queuing & State Initialization
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-02-12 08:34:51 +01:00
|
|
|
|
setConnectionStatus(status) {
|
|
|
|
|
|
this._connectionStatus = status;
|
|
|
|
|
|
this._updateConnectionIndicator();
|
|
|
|
|
|
if (status === 'connected' && this._inputQueue.size > 0) {
|
|
|
|
|
|
this._drainInputQueues();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 21:02:28 -04:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// WebSocket Terminal I/O
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Open a WebSocket for terminal I/O on the given session.
|
|
|
|
|
|
* Replaces HTTP POST input and SSE terminal output with a single
|
|
|
|
|
|
* bidirectional connection. Falls back to SSE+POST if WS fails.
|
|
|
|
|
|
*/
|
|
|
|
|
|
_connectWs(sessionId) {
|
|
|
|
|
|
this._disconnectWs();
|
|
|
|
|
|
|
|
|
|
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
|
|
|
|
const url = `${proto}//${location.host}/ws/sessions/${sessionId}/terminal`;
|
|
|
|
|
|
const ws = new WebSocket(url);
|
|
|
|
|
|
this._ws = ws;
|
|
|
|
|
|
this._wsSessionId = sessionId;
|
|
|
|
|
|
|
|
|
|
|
|
ws.onopen = () => {
|
|
|
|
|
|
// Only mark ready if this is still the intended session
|
|
|
|
|
|
if (this._ws === ws) {
|
|
|
|
|
|
this._wsReady = true;
|
2026-03-14 18:37:10 +01:00
|
|
|
|
this._wsReconnectAttempts = 0;
|
2026-03-12 21:02:28 -04:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
ws.onmessage = (event) => {
|
|
|
|
|
|
if (this._ws !== ws) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const msg = JSON.parse(event.data);
|
|
|
|
|
|
if (msg.t === 'o') {
|
|
|
|
|
|
// Terminal output — route through the same batching pipeline as SSE
|
|
|
|
|
|
this._onSessionTerminal({ id: sessionId, data: msg.d });
|
|
|
|
|
|
} else if (msg.t === 'c') {
|
|
|
|
|
|
this._onSessionClearTerminal({ id: sessionId });
|
|
|
|
|
|
} else if (msg.t === 'r') {
|
|
|
|
|
|
this._onSessionNeedsRefresh({ id: sessionId });
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Ignore malformed messages
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-14 18:37:10 +01:00
|
|
|
|
ws.onclose = (event) => {
|
|
|
|
|
|
if (this._ws !== ws) return;
|
|
|
|
|
|
this._ws = null;
|
|
|
|
|
|
this._wsSessionId = null;
|
|
|
|
|
|
this._wsReady = false;
|
|
|
|
|
|
|
|
|
|
|
|
// Reconnect on unexpected close (server restart, network blip, ping timeout).
|
|
|
|
|
|
// Don't reconnect if we intentionally disconnected (_disconnectWs nulls onclose)
|
|
|
|
|
|
// or if the server rejected the session (4004=not found, 4008=too many, 4009=terminated).
|
|
|
|
|
|
if (event.code < 4004 && this.activeSessionId === sessionId) {
|
|
|
|
|
|
const delay = Math.min(1000 * Math.pow(2, this._wsReconnectAttempts || 0), 10000);
|
|
|
|
|
|
this._wsReconnectAttempts = (this._wsReconnectAttempts || 0) + 1;
|
|
|
|
|
|
this._wsReconnectTimer = setTimeout(() => {
|
|
|
|
|
|
this._wsReconnectTimer = null;
|
|
|
|
|
|
if (this.activeSessionId === sessionId) {
|
|
|
|
|
|
this._connectWs(sessionId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, delay);
|
2026-03-12 21:02:28 -04:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
ws.onerror = () => {
|
|
|
|
|
|
// onclose will fire after onerror — cleanup happens there
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Close the active WebSocket connection (if any). */
|
|
|
|
|
|
_disconnectWs() {
|
2026-03-25 23:32:28 +01:00
|
|
|
|
this._clearTimer('_wsReconnectTimer');
|
2026-03-14 18:37:10 +01:00
|
|
|
|
this._wsReconnectAttempts = 0;
|
2026-03-12 21:02:28 -04:00
|
|
|
|
if (this._ws) {
|
|
|
|
|
|
this._ws.onclose = null; // Prevent re-entrant cleanup
|
|
|
|
|
|
this._ws.close();
|
|
|
|
|
|
this._ws = null;
|
|
|
|
|
|
this._wsSessionId = null;
|
|
|
|
|
|
this._wsReady = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 06:02:12 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 21:02:28 -04:00
|
|
|
|
// Fast path: WebSocket — fire-and-forget, inherently ordered (single TCP stream).
|
|
|
|
|
|
if (this._wsReady && this._wsSessionId === sessionId) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
this._ws.send(JSON.stringify({ t: 'i', d: input }));
|
|
|
|
|
|
this.clearPendingHooks(sessionId);
|
|
|
|
|
|
return;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// WS send failed — fall through to HTTP POST
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Slow path: HTTP POST — chain on dispatch only, don't wait for response.
|
|
|
|
|
|
// The server handles writeViaMux as fire-and-forget anyway.
|
2026-02-20 14:04:16 +01:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-02-13 21:07:49 +01:00
|
|
|
|
|
2026-02-20 14:04:16 +01:00
|
|
|
|
// Handle response asynchronously — don't block next keystroke on response
|
|
|
|
|
|
fetchPromise.then(resp => {
|
|
|
|
|
|
if (!resp.ok) {
|
|
|
|
|
|
this._enqueueInput(sessionId, input);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.clearPendingHooks(sessionId);
|
2026-02-13 06:02:12 +01:00
|
|
|
|
}
|
2026-02-20 14:04:16 +01:00
|
|
|
|
}).catch(() => {
|
|
|
|
|
|
this._enqueueInput(sessionId, input);
|
|
|
|
|
|
});
|
2026-02-13 06:02:12 +01:00
|
|
|
|
|
2026-02-20 14:04:16 +01:00
|
|
|
|
// Return immediately after fetch is dispatched (don't await response)
|
2026-02-20 13:06:45 +01:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 14:04:16 +01:00
|
|
|
|
|
2026-02-12 08:34:51 +01:00
|
|
|
|
_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) {
|
2026-03-01 06:05:26 +01:00
|
|
|
|
const resp = await this._apiPost(`/api/sessions/${sessionId}/input`, { input });
|
|
|
|
|
|
if (!resp?.ok) {
|
2026-02-12 08:34:51 +01:00
|
|
|
|
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';
|
|
|
|
|
|
}
|
2026-01-30 13:25:18 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setupOnlineDetection() {
|
|
|
|
|
|
window.addEventListener('online', () => {
|
|
|
|
|
|
this.isOnline = true;
|
|
|
|
|
|
this.reconnectAttempts = 0;
|
|
|
|
|
|
this.connectSSE();
|
|
|
|
|
|
});
|
|
|
|
|
|
window.addEventListener('offline', () => {
|
|
|
|
|
|
this.isOnline = false;
|
|
|
|
|
|
this.setConnectionStatus('offline');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 09:22:06 +08:00
|
|
|
|
/** Show/hide the CJK input textarea based on user setting or server override */
|
|
|
|
|
|
_updateCjkInputState() {
|
|
|
|
|
|
const cjkEl = document.getElementById('cjkInput');
|
|
|
|
|
|
if (!cjkEl) return;
|
|
|
|
|
|
const settings = this.loadAppSettingsFromStorage();
|
|
|
|
|
|
const showCjk = this._serverCjkOverride || settings.cjkInputEnabled || false;
|
|
|
|
|
|
cjkEl.style.display = showCjk ? 'block' : 'none';
|
|
|
|
|
|
if (!showCjk) window.cjkActive = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 23:32:28 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Reset all app state maps, timers, and handlers to a clean baseline.
|
|
|
|
|
|
* Called by handleInit() on SSE reconnect / page reload to prevent
|
|
|
|
|
|
* memory leaks and stale data.
|
|
|
|
|
|
*/
|
|
|
|
|
|
_resetAllAppState() {
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this.sessions.clear();
|
2026-01-21 22:36:45 +01:00
|
|
|
|
this.ralphStates.clear();
|
2026-01-27 17:46:43 +01:00
|
|
|
|
this.terminalBuffers.clear();
|
2026-02-13 21:07:49 +01:00
|
|
|
|
this.terminalBufferCache.clear();
|
2026-01-27 17:46:43 +01:00
|
|
|
|
this.projectInsights.clear();
|
2026-02-15 05:33:34 +01:00
|
|
|
|
this.teams.clear();
|
|
|
|
|
|
this.teamTasks.clear();
|
2026-01-27 17:46:43 +01:00
|
|
|
|
// Clear all idle timers to prevent stale timers from firing
|
|
|
|
|
|
for (const timer of this.idleTimers.values()) {
|
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
|
}
|
|
|
|
|
|
this.idleTimers.clear();
|
2026-01-30 17:50:02 +01:00
|
|
|
|
// Clear flicker filter state
|
2026-03-25 23:32:28 +01:00
|
|
|
|
this._clearTimer('flickerFilterTimeout');
|
2026-01-30 17:50:02 +01:00
|
|
|
|
this.flickerFilterBuffer = '';
|
|
|
|
|
|
this.flickerFilterActive = false;
|
2026-02-02 04:49:15 +01:00
|
|
|
|
// Clear pending terminal writes
|
2026-03-25 23:32:28 +01:00
|
|
|
|
this._clearTimer('syncWaitTimeout');
|
2026-02-12 20:07:36 +01:00
|
|
|
|
this.pendingWrites = [];
|
2026-02-02 04:49:15 +01:00
|
|
|
|
this.writeFrameScheduled = false;
|
2026-02-18 21:53:34 +01:00
|
|
|
|
this._isLoadingBuffer = false;
|
|
|
|
|
|
this._loadBufferQueue = null;
|
2026-03-22 03:00:58 +01:00
|
|
|
|
// Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
|
|
|
|
|
|
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
|
2026-02-19 14:52:39 +01:00
|
|
|
|
// 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();
|
2026-01-27 17:46:43 +01:00
|
|
|
|
// Clear pending hooks
|
|
|
|
|
|
this.pendingHooks.clear();
|
2026-02-27 17:54:59 +01:00
|
|
|
|
// Clear parent name cache (prevents stale session name entries accumulating)
|
|
|
|
|
|
if (this._parentNameCache) this._parentNameCache.clear();
|
2026-02-18 07:06:34 +01:00
|
|
|
|
// Clear subagent activity/results maps (prevents leaks if data.subagents is missing)
|
|
|
|
|
|
this.subagentActivity.clear();
|
|
|
|
|
|
this.subagentToolResults.clear();
|
2026-02-18 11:59:42 +01:00
|
|
|
|
// Clean up mobile/keyboard handlers and re-init (prevents listener accumulation on reconnect)
|
2026-02-18 07:06:34 +01:00
|
|
|
|
MobileDetection.cleanup();
|
|
|
|
|
|
KeyboardHandler.cleanup();
|
2026-02-18 11:59:42 +01:00
|
|
|
|
MobileDetection.init();
|
|
|
|
|
|
KeyboardHandler.init();
|
2026-01-27 17:46:43 +01:00
|
|
|
|
// Clear tab alerts
|
|
|
|
|
|
this.tabAlerts.clear();
|
|
|
|
|
|
// Clear shown completions (used for duplicate notification prevention)
|
|
|
|
|
|
if (this._shownCompletions) {
|
|
|
|
|
|
this._shownCompletions.clear();
|
|
|
|
|
|
}
|
2026-01-27 20:24:00 +01:00
|
|
|
|
// Clear notification manager title flash interval to prevent memory leak
|
|
|
|
|
|
if (this.notificationManager?.titleFlashInterval) {
|
|
|
|
|
|
clearInterval(this.notificationManager.titleFlashInterval);
|
|
|
|
|
|
this.notificationManager.titleFlashInterval = null;
|
|
|
|
|
|
}
|
2026-02-04 07:24:32 +01:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-01-27 20:24:00 +01:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-03-25 23:32:28 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
handleInit(data) {
|
|
|
|
|
|
// Clear the init fallback timer since we got data
|
|
|
|
|
|
this._clearTimer('_initFallbackTimer');
|
|
|
|
|
|
const gen = ++this._initGeneration;
|
|
|
|
|
|
|
|
|
|
|
|
// CJK input form: controlled by user setting (with server env as override)
|
|
|
|
|
|
this._serverCjkOverride = data.inputCjkForm || false;
|
|
|
|
|
|
this._updateCjkInputState();
|
|
|
|
|
|
|
|
|
|
|
|
// 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._resetAllAppState();
|
|
|
|
|
|
|
2026-01-19 13:21:01 +01:00
|
|
|
|
data.sessions.forEach(s => {
|
|
|
|
|
|
this.sessions.set(s.id, s);
|
2026-01-30 17:50:02 +01:00
|
|
|
|
// Load ralph state from session data (only if not explicitly closed by user)
|
|
|
|
|
|
if ((s.ralphLoop || s.ralphTodos) && !this.ralphClosedSessions.has(s.id)) {
|
2026-01-21 22:36:45 +01:00
|
|
|
|
this.ralphStates.set(s.id, {
|
|
|
|
|
|
loop: s.ralphLoop || null,
|
|
|
|
|
|
todos: s.ralphTodos || []
|
2026-01-19 13:21:01 +01:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-01-18 05:14:34 +01:00
|
|
|
|
|
2026-03-10 14:30:21 +08:00
|
|
|
|
// Restore tabs that were open before refresh but are no longer on the server
|
|
|
|
|
|
this._restoreEndedTabs();
|
|
|
|
|
|
|
2026-01-29 12:47:23 +01:00
|
|
|
|
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
|
|
|
|
|
|
this.syncSessionOrder();
|
|
|
|
|
|
|
2026-01-19 00:38:11 +01:00
|
|
|
|
if (data.respawnStatus) {
|
|
|
|
|
|
this.respawnStatus = data.respawnStatus;
|
2026-01-27 17:46:43 +01:00
|
|
|
|
} else {
|
|
|
|
|
|
// Clear respawn status on init if not provided (prevents stale data)
|
|
|
|
|
|
this.respawnStatus = {};
|
2026-01-19 00:38:11 +01:00
|
|
|
|
}
|
2026-01-27 17:46:43 +01:00
|
|
|
|
// Clean up respawn state for sessions that no longer exist
|
|
|
|
|
|
this.respawnTimers = {};
|
|
|
|
|
|
this.respawnCountdownTimers = {};
|
|
|
|
|
|
this.respawnActionLogs = {};
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
2026-01-25 07:26:49 +01:00
|
|
|
|
// Store global stats for aggregate tracking
|
|
|
|
|
|
if (data.globalStats) {
|
|
|
|
|
|
this.globalStats = data.globalStats;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.totalCost = data.sessions.reduce((sum, s) => sum + (s.totalCost || 0), 0);
|
|
|
|
|
|
this.totalCost += data.scheduledRuns.reduce((sum, r) => sum + (r.totalCost || 0), 0);
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
const activeRun = data.scheduledRuns.find(r => r.status === 'running');
|
|
|
|
|
|
if (activeRun) {
|
|
|
|
|
|
this.currentRun = activeRun;
|
|
|
|
|
|
this.showTimer();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.updateCost();
|
|
|
|
|
|
this.renderSessionTabs();
|
|
|
|
|
|
|
2026-02-18 11:28:34 +01:00
|
|
|
|
// Start/stop system stats polling based on session count
|
|
|
|
|
|
if (this.sessions.size > 0) {
|
|
|
|
|
|
this.startSystemStatsPolling();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.stopSystemStatsPolling();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-27 20:24:00 +01:00
|
|
|
|
// CRITICAL: Clean up all floating windows before loading new subagents
|
|
|
|
|
|
// This prevents memory leaks from ResizeObservers, EventSources, and DOM elements
|
|
|
|
|
|
this.cleanupAllFloatingWindows();
|
|
|
|
|
|
|
2026-01-27 17:46:43 +01:00
|
|
|
|
// Load subagents - clear all related maps to prevent memory leaks on reconnect
|
2026-01-25 14:33:12 +01:00
|
|
|
|
if (data.subagents) {
|
|
|
|
|
|
this.subagents.clear();
|
2026-01-27 17:46:43 +01:00
|
|
|
|
this.subagentActivity.clear();
|
|
|
|
|
|
this.subagentToolResults.clear();
|
2026-01-25 14:33:12 +01:00
|
|
|
|
data.subagents.forEach(s => {
|
|
|
|
|
|
this.subagents.set(s.agentId, s);
|
|
|
|
|
|
});
|
|
|
|
|
|
this.renderSubagentPanel();
|
2026-01-26 09:06:49 +01:00
|
|
|
|
|
2026-01-29 14:56:11 +01:00
|
|
|
|
// 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();
|
|
|
|
|
|
});
|
2026-01-25 14:33:12 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 20:17:10 +01:00
|
|
|
|
// 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.
|
2026-02-13 05:07:51 +01:00
|
|
|
|
// Guard: skip if a newer handleInit has already started (race between loadState + SSE init).
|
|
|
|
|
|
if (gen !== this._initGeneration) return;
|
2026-02-12 20:17:10 +01:00
|
|
|
|
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)) {
|
2026-02-26 16:44:34 +01:00
|
|
|
|
try { restoreId = localStorage.getItem('codeman-active-session'); } catch {}
|
2026-02-12 20:17:10 +01:00
|
|
|
|
}
|
2026-02-12 20:07:36 +01:00
|
|
|
|
if (restoreId && this.sessions.has(restoreId)) {
|
|
|
|
|
|
this.selectSession(restoreId);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.selectSession(this.sessionOrder[0]);
|
|
|
|
|
|
}
|
2026-01-18 07:31:19 +01:00
|
|
|
|
}
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 23:55:39 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Debounce Utility
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
/** Debounce a method call using a named timer key. */
|
|
|
|
|
|
_debouncedCall(timerKey, fn, delayMs = 100) {
|
|
|
|
|
|
if (this._debounceTimers[timerKey]) {
|
|
|
|
|
|
clearTimeout(this._debounceTimers[timerKey]);
|
|
|
|
|
|
}
|
|
|
|
|
|
this._debounceTimers[timerKey] = setTimeout(() => {
|
|
|
|
|
|
this._debounceTimers[timerKey] = null;
|
|
|
|
|
|
fn.call(this);
|
|
|
|
|
|
}, delayMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Session Tabs
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
|
|
|
|
|
renderSessionTabs() {
|
2026-03-22 23:55:39 +01:00
|
|
|
|
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
|
2026-01-19 06:51:25 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 21:07:49 +01:00
|
|
|
|
/** 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');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 06:51:25 +01:00
|
|
|
|
_renderSessionTabsImmediate() {
|
2026-01-19 11:48:02 +01:00
|
|
|
|
const container = this.$('sessionTabs');
|
2026-01-21 06:06:57 +01:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-24 03:49:19 +01:00
|
|
|
|
// 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'); }
|
|
|
|
|
|
|
2026-01-21 06:06:57 +01:00
|
|
|
|
// 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) {
|
2026-04-11 06:59:50 +02:00
|
|
|
|
const _p = parseSessionPrefix(name);
|
|
|
|
|
|
if (_p && _p.suffix) {
|
|
|
|
|
|
nameEl.innerHTML = '<span class="tab-prefix">' + escapeHtml(_p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(_p.suffix) + '</span>';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
nameEl.textContent = name;
|
|
|
|
|
|
}
|
2026-01-21 06:06:57 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-01-26 02:21:34 +01:00
|
|
|
|
|
2026-03-07 06:14:16 +01:00
|
|
|
|
// Update subagent badge - targeted update without full rebuild
|
2026-01-26 02:21:34 +01:00
|
|
|
|
const subagentBadgeEl = tab.querySelector('.tab-subagent-badge');
|
|
|
|
|
|
const minimizedAgents = this.minimizedSubagents.get(id);
|
|
|
|
|
|
const minimizedCount = minimizedAgents?.size || 0;
|
2026-03-07 06:14:16 +01:00
|
|
|
|
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();
|
2026-01-26 02:21:34 +01:00
|
|
|
|
}
|
2026-01-21 06:06:57 +01:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Full rebuild needed (sessions added/removed)
|
|
|
|
|
|
this._fullRenderSessionTabs();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_fullRenderSessionTabs() {
|
|
|
|
|
|
const container = this.$('sessionTabs');
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
2026-01-26 19:42:01 +01:00
|
|
|
|
// Clean up any orphaned dropdowns before re-rendering
|
|
|
|
|
|
document.querySelectorAll('body > .subagent-dropdown').forEach(d => d.remove());
|
|
|
|
|
|
this.cancelHideSubagentDropdown();
|
|
|
|
|
|
|
2026-01-19 11:48:02 +01:00
|
|
|
|
// Build tabs HTML using array for better string concatenation performance
|
2026-01-29 12:47:23 +01:00
|
|
|
|
// Iterate in sessionOrder to respect user's custom tab arrangement
|
2026-01-31 04:44:46 +01:00
|
|
|
|
// On mobile: put active session first (only one tab visible anyway)
|
2026-01-19 11:48:02 +01:00
|
|
|
|
const parts = [];
|
2026-01-31 04:44:46 +01:00
|
|
|
|
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) {
|
2026-01-29 12:47:23 +01:00
|
|
|
|
const session = this.sessions.get(id);
|
|
|
|
|
|
if (!session) continue; // Skip if session was removed
|
|
|
|
|
|
|
2026-01-19 00:38:11 +01:00
|
|
|
|
const isActive = id === this.activeSessionId;
|
|
|
|
|
|
const status = session.status || 'idle';
|
|
|
|
|
|
const name = this.getSessionName(session);
|
2026-01-19 01:26:38 +01:00
|
|
|
|
const mode = session.mode || 'claude';
|
2026-01-30 13:25:18 +01:00
|
|
|
|
const color = session.color || 'default';
|
2026-01-19 00:38:11 +01:00
|
|
|
|
const taskStats = session.taskStats || { running: 0, total: 0 };
|
|
|
|
|
|
const hasRunningTasks = taskStats.running > 0;
|
2026-01-24 03:49:19 +01:00
|
|
|
|
const alertType = this.tabAlerts.get(id);
|
|
|
|
|
|
const alertClass = alertType === 'action' ? ' tab-alert-action' : alertType === 'idle' ? ' tab-alert-idle' : '';
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
2026-01-26 02:21:34 +01:00
|
|
|
|
// 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) : '';
|
|
|
|
|
|
|
2026-02-18 11:15:15 +01:00
|
|
|
|
// Show folder name if session has a custom name AND tall tabs setting is enabled
|
2026-02-18 07:28:15 +01:00
|
|
|
|
const folderName = session.workingDir ? session.workingDir.split('/').pop() || '' : '';
|
2026-02-18 11:15:15 +01:00
|
|
|
|
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
|
|
|
|
|
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
2026-02-18 07:28:15 +01:00
|
|
|
|
|
2026-03-10 14:30:21 +08:00
|
|
|
|
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)}"` : ''}>
|
2026-01-30 13:25:18 +01:00
|
|
|
|
<span class="tab-status ${status}" aria-hidden="true"></span>
|
2026-02-18 07:28:15 +01:00
|
|
|
|
<span class="tab-info">
|
|
|
|
|
|
<span class="tab-name-row">
|
2026-02-26 10:16:20 +01:00
|
|
|
|
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
|
2026-04-11 06:59:50 +02:00
|
|
|
|
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
|
2026-02-18 07:28:15 +01:00
|
|
|
|
</span>
|
2026-03-01 04:57:37 +01:00
|
|
|
|
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
|
2026-02-18 07:28:15 +01:00
|
|
|
|
</span>
|
2026-01-30 13:25:18 +01:00
|
|
|
|
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
|
2026-01-26 02:21:34 +01:00
|
|
|
|
${subagentBadge}
|
2026-03-01 04:57:37 +01:00
|
|
|
|
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
|
|
|
|
|
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
2026-01-19 11:48:02 +01:00
|
|
|
|
</div>`);
|
2026-01-19 00:38:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 11:48:02 +01:00
|
|
|
|
container.innerHTML = parts.join('');
|
2026-01-29 12:47:23 +01:00
|
|
|
|
|
2026-03-10 14:30:21 +08:00
|
|
|
|
// Persist tab metadata for refresh recovery
|
|
|
|
|
|
this._saveTabMetadata();
|
|
|
|
|
|
|
2026-01-29 12:47:23 +01:00
|
|
|
|
// Set up drag-and-drop handlers for tab reordering
|
|
|
|
|
|
this.setupTabDragHandlers();
|
|
|
|
|
|
|
2026-01-30 13:25:18 +01:00
|
|
|
|
// Set up keyboard navigation for tabs
|
|
|
|
|
|
this.setupTabKeyboardNavigation(container);
|
|
|
|
|
|
|
2026-01-25 23:41:59 +01:00
|
|
|
|
// Update connection lines after tabs change (positions may have shifted)
|
|
|
|
|
|
this.updateConnectionLines();
|
2026-01-19 00:38:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-30 13:25:18 +01:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-26 02:21:34 +01:00
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Tab Order and Drag-and-Drop
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
2026-01-29 12:47:23 +01:00
|
|
|
|
|
|
|
|
|
|
// 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 {
|
2026-02-26 16:44:34 +01:00
|
|
|
|
const saved = localStorage.getItem('codeman-session-order');
|
2026-01-29 12:47:23 +01:00
|
|
|
|
return saved ? JSON.parse(saved) : [];
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Save session order to localStorage
|
|
|
|
|
|
saveSessionOrder() {
|
|
|
|
|
|
try {
|
2026-02-26 16:44:34 +01:00
|
|
|
|
localStorage.setItem('codeman-session-order', JSON.stringify(this.sessionOrder));
|
2026-01-29 12:47:23 +01:00
|
|
|
|
} catch {
|
|
|
|
|
|
// Ignore storage errors
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 14:30:21 +08:00
|
|
|
|
// 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 */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 12:47:23 +01:00
|
|
|
|
// 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();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Session Lifecycle — select, close, navigate
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
2026-01-26 02:21:34 +01:00
|
|
|
|
|
2026-03-03 22:56:40 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 00:38:11 +01:00
|
|
|
|
getSessionName(session) {
|
2026-01-19 01:26:38 +01:00
|
|
|
|
// Use custom name if set
|
|
|
|
|
|
if (session.name) {
|
|
|
|
|
|
return session.name;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Fall back to directory name
|
2026-01-19 00:38:11 +01:00
|
|
|
|
if (session.workingDir) {
|
|
|
|
|
|
return session.workingDir.split('/').pop() || session.workingDir;
|
|
|
|
|
|
}
|
2026-03-03 22:56:40 +01:00
|
|
|
|
return this.getShortId(session.id);
|
2026-01-19 00:38:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 22:50:25 +01:00
|
|
|
|
_notifySession(sessionId, urgency, category, title, message) {
|
|
|
|
|
|
const session = this.sessions.get(sessionId);
|
|
|
|
|
|
this.notificationManager?.notify({
|
|
|
|
|
|
urgency,
|
|
|
|
|
|
category,
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
sessionName: session?.name || this.getShortId(sessionId),
|
|
|
|
|
|
title,
|
|
|
|
|
|
message,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 23:32:28 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Clean up state from the previous session before switching tabs.
|
|
|
|
|
|
* Handles: WebSocket teardown, CJK clear, flicker filter, tab completion,
|
|
|
|
|
|
* terminal write queue, IME composition, and local echo flush.
|
|
|
|
|
|
* @param {string} newSessionId - The session being switched TO.
|
|
|
|
|
|
*/
|
|
|
|
|
|
_cleanupPreviousSession(newSessionId) {
|
2026-03-12 21:02:28 -04:00
|
|
|
|
// Close WebSocket for previous session (new one opens after buffer load)
|
|
|
|
|
|
this._disconnectWs();
|
|
|
|
|
|
|
2026-03-14 18:37:10 +01:00
|
|
|
|
// Clear CJK textarea to prevent sending stale text to the wrong session
|
|
|
|
|
|
const cjkEl = document.getElementById('cjkInput');
|
|
|
|
|
|
if (cjkEl) cjkEl.value = '';
|
|
|
|
|
|
|
2026-01-30 17:50:02 +01:00
|
|
|
|
// Clean up flicker filter state when switching sessions
|
2026-03-25 23:32:28 +01:00
|
|
|
|
this._clearTimer('flickerFilterTimeout');
|
2026-01-30 17:50:02 +01:00
|
|
|
|
this.flickerFilterBuffer = '';
|
|
|
|
|
|
this.flickerFilterActive = false;
|
|
|
|
|
|
|
2026-02-22 07:34:33 +01:00
|
|
|
|
// Clear tab completion detection flag — don't carry across sessions
|
|
|
|
|
|
this._tabCompletionSessionId = null;
|
2026-02-22 09:23:26 +01:00
|
|
|
|
this._tabCompletionRetries = 0;
|
|
|
|
|
|
this._tabCompletionBaseText = null;
|
2026-03-25 23:32:28 +01:00
|
|
|
|
this._clearTimer('_tabCompletionFallback');
|
|
|
|
|
|
this._clearTimer('_clientDropRecoveryTimer');
|
2026-02-22 07:34:33 +01:00
|
|
|
|
|
2026-02-02 04:49:15 +01:00
|
|
|
|
// Clean up pending terminal writes to prevent old session data from appearing in new session
|
2026-03-25 23:32:28 +01:00
|
|
|
|
this._clearTimer('syncWaitTimeout');
|
2026-02-12 20:07:36 +01:00
|
|
|
|
this.pendingWrites = [];
|
2026-02-02 04:49:15 +01:00
|
|
|
|
this.writeFrameScheduled = false;
|
2026-02-18 21:53:34 +01:00
|
|
|
|
this._isLoadingBuffer = false;
|
|
|
|
|
|
this._loadBufferQueue = null;
|
2026-03-22 03:00:58 +01:00
|
|
|
|
// Abort any in-flight chunkedTerminalWrite from the previous session.
|
|
|
|
|
|
// Without this, old rAF-scheduled chunks continue writing stale data
|
|
|
|
|
|
// into the terminal, interleaving with the new session's buffer.
|
|
|
|
|
|
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
|
2026-02-21 14:00:16 +01:00
|
|
|
|
// 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: '' }));
|
2026-02-20 03:18:53 +01:00
|
|
|
|
}
|
2026-02-21 14:00:16 +01:00
|
|
|
|
} 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.
|
2026-02-19 21:44:03 +01:00
|
|
|
|
if (this.activeSessionId) {
|
|
|
|
|
|
const echoText = this._localEchoOverlay?.pendingText || '';
|
2026-02-22 05:45:04 +01:00
|
|
|
|
// Include buffer-detected flushed text (from Tab completion, etc.)
|
|
|
|
|
|
// so it's preserved across tab switches.
|
2026-02-22 11:41:44 +01:00
|
|
|
|
const existingFlushed = this._localEchoOverlay?.getFlushed()?.count || 0;
|
|
|
|
|
|
const existingFlushedText = this._localEchoOverlay?.getFlushed()?.text || '';
|
2026-02-19 21:44:03 +01:00
|
|
|
|
if (echoText) {
|
2026-02-21 14:00:16 +01:00
|
|
|
|
this._sendInputAsync(this.activeSessionId, echoText);
|
2026-02-22 05:45:04 +01:00
|
|
|
|
}
|
|
|
|
|
|
const totalOffset = existingFlushed + echoText.length;
|
|
|
|
|
|
if (totalOffset > 0) {
|
2026-02-21 14:00:16 +01:00
|
|
|
|
if (!this._flushedOffsets) this._flushedOffsets = new Map();
|
2026-02-22 02:05:49 +01:00
|
|
|
|
if (!this._flushedTexts) this._flushedTexts = new Map();
|
2026-02-22 05:45:04 +01:00
|
|
|
|
this._flushedOffsets.set(this.activeSessionId, totalOffset);
|
|
|
|
|
|
this._flushedTexts.set(this.activeSessionId, existingFlushedText + echoText);
|
2026-02-19 21:44:03 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-18 21:53:34 +01:00
|
|
|
|
this._localEchoOverlay?.clear();
|
2026-02-22 09:23:26 +01:00
|
|
|
|
// 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.
|
2026-03-25 23:32:28 +01:00
|
|
|
|
if (this._localEchoOverlay && !this._flushedOffsets?.has(newSessionId)) {
|
2026-02-22 11:41:44 +01:00
|
|
|
|
this._localEchoOverlay.suppressBufferDetection();
|
2026-02-22 09:23:26 +01:00
|
|
|
|
}
|
2026-03-25 23:32:28 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async selectSession(sessionId) {
|
|
|
|
|
|
if (this.activeSessionId === sessionId) return;
|
|
|
|
|
|
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
|
|
|
|
|
|
// programmatic focus() within the user-gesture call stack (e.g. tab click).
|
|
|
|
|
|
// After the first await the gesture context is lost and focus() is silently
|
|
|
|
|
|
// ignored, leaving the keyboard unable to send input to the terminal.
|
|
|
|
|
|
if (this.terminal) this.terminal.focus();
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
this._cleanupPreviousSession(sessionId);
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.activeSessionId = sessionId;
|
2026-02-26 16:44:34 +01:00
|
|
|
|
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
|
2026-01-29 12:47:23 +01:00
|
|
|
|
this.hideWelcome();
|
2026-01-26 23:00:47 +01:00
|
|
|
|
// Clear idle hooks on view, but keep action hooks until user interacts
|
|
|
|
|
|
this.clearPendingHooks(sessionId, 'idle_prompt');
|
2026-02-13 21:07:49 +01:00
|
|
|
|
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
|
|
|
|
|
|
this._updateActiveTabImmediate(sessionId);
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.renderSessionTabs();
|
2026-02-18 21:53:34 +01:00
|
|
|
|
this._updateLocalEchoState();
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
2026-02-22 02:05:49 +01:00
|
|
|
|
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
|
2026-02-21 23:37:29 +01:00
|
|
|
|
// 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) {
|
2026-02-22 11:41:44 +01:00
|
|
|
|
this._localEchoOverlay.setFlushed(
|
|
|
|
|
|
this._flushedOffsets.get(sessionId),
|
|
|
|
|
|
this._flushedTexts?.get(sessionId) || '',
|
|
|
|
|
|
false // render=false: buffer not loaded yet
|
|
|
|
|
|
);
|
2026-02-21 23:37:29 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 08:34:51 +01:00
|
|
|
|
// 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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 19:56:28 +01:00
|
|
|
|
// Check if this is a restored session that needs to be attached
|
|
|
|
|
|
const session = this.sessions.get(sessionId);
|
2026-01-26 02:21:34 +01:00
|
|
|
|
|
2026-03-10 14:30:21 +08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-26 02:21:34 +01:00
|
|
|
|
// Track working directory for path normalization in Project Insights
|
|
|
|
|
|
this.currentSessionWorkingDir = session?.workingDir || null;
|
2026-04-02 21:35:00 +08:00
|
|
|
|
if (session && session.pid === null && !session._ended) {
|
|
|
|
|
|
// Session has no PTY attached — either restored after server restart
|
|
|
|
|
|
// or detached for some other reason. Re-attach regardless of status.
|
2026-02-02 05:14:18 +01:00
|
|
|
|
try {
|
2026-03-10 02:23:10 +08:00
|
|
|
|
const endpoint = session.mode === 'shell'
|
|
|
|
|
|
? `/api/sessions/${sessionId}/shell`
|
|
|
|
|
|
: `/api/sessions/${sessionId}/interactive`;
|
|
|
|
|
|
await fetch(endpoint, { method: 'POST' });
|
2026-02-02 05:14:18 +01:00
|
|
|
|
// Update local session state
|
|
|
|
|
|
session.status = 'busy';
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Failed to attach to restored session:', err);
|
2026-01-19 00:38:11 +01:00
|
|
|
|
}
|
2026-02-02 05:08:51 +01:00
|
|
|
|
}
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
2026-02-02 05:14:18 +01:00
|
|
|
|
// Load terminal buffer for this session
|
2026-02-13 21:07:49 +01:00
|
|
|
|
// Show cached content instantly while fetching fresh data in background.
|
2026-03-07 07:00:18 +01:00
|
|
|
|
// Use tail mode for faster initial load (128KB is enough for recent visible content).
|
2026-02-22 02:49:19 +01:00
|
|
|
|
//
|
|
|
|
|
|
// 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;
|
2026-03-05 17:52:28 +01:00
|
|
|
|
// 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 = [];
|
2026-02-02 05:14:18 +01:00
|
|
|
|
try {
|
2026-03-22 03:00:58 +01:00
|
|
|
|
// Fit terminal to container BEFORE writing any buffer data.
|
|
|
|
|
|
// If the browser was resized while viewing another session, the terminal
|
|
|
|
|
|
// canvas may be at stale dimensions — content would render at wrong width.
|
|
|
|
|
|
if (this.fitAddon) this.fitAddon.fit();
|
|
|
|
|
|
|
2026-03-23 12:32:33 +01:00
|
|
|
|
// Instant cache restore for IDLE sessions only.
|
|
|
|
|
|
// For busy sessions, the cache is always stale — writing it first causes a
|
|
|
|
|
|
// jarring double-render: stale content appears, then the terminal flashes
|
|
|
|
|
|
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
|
|
|
|
|
// buffer once for a single clean transition.
|
2026-02-13 21:07:49 +01:00
|
|
|
|
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
2026-03-23 12:32:33 +01:00
|
|
|
|
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
|
|
|
|
|
|
if (cachedBuffer && !sessionIsBusy) {
|
2026-03-05 16:44:17 +01:00
|
|
|
|
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
2026-02-13 21:07:49 +01:00
|
|
|
|
this.terminal.clear();
|
|
|
|
|
|
this.terminal.reset();
|
2026-03-04 13:09:10 +01:00
|
|
|
|
await this.chunkedTerminalWrite(cachedBuffer);
|
2026-03-25 23:32:28 +01:00
|
|
|
|
if (this._isStaleSelect(selectGen)) return;
|
2026-02-13 21:07:49 +01:00
|
|
|
|
this.terminal.scrollToBottom();
|
2026-03-05 16:44:17 +01:00
|
|
|
|
_crashDiag.log('CACHE_DONE');
|
2026-03-23 12:32:33 +01:00
|
|
|
|
} else if (sessionIsBusy) {
|
|
|
|
|
|
// Clear stale content immediately — fresh buffer is being fetched
|
|
|
|
|
|
this.terminal.clear();
|
|
|
|
|
|
this.terminal.reset();
|
|
|
|
|
|
_crashDiag.log('CACHE_SKIP_BUSY');
|
2026-02-13 21:07:49 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 16:44:17 +01:00
|
|
|
|
_crashDiag.log('FETCH_START');
|
2026-03-07 07:00:18 +01:00
|
|
|
|
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
2026-03-25 23:32:28 +01:00
|
|
|
|
if (this._isStaleSelect(selectGen)) return;
|
2026-02-02 05:14:18 +01:00
|
|
|
|
const data = await res.json();
|
2026-03-05 16:44:17 +01:00
|
|
|
|
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
2026-02-02 05:14:18 +01:00
|
|
|
|
|
|
|
|
|
|
if (data.terminalBuffer) {
|
2026-02-20 15:41:43 +01:00
|
|
|
|
// 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) {
|
2026-03-05 16:44:17 +01:00
|
|
|
|
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
|
2026-02-20 15:41:43 +01:00
|
|
|
|
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);
|
2026-03-25 23:32:28 +01:00
|
|
|
|
if (this._isStaleSelect(selectGen)) return;
|
2026-02-20 15:41:43 +01:00
|
|
|
|
// Ensure terminal is scrolled to bottom after buffer load
|
|
|
|
|
|
this.terminal.scrollToBottom();
|
2026-02-02 05:14:18 +01:00
|
|
|
|
}
|
2026-02-13 21:07:49 +01:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-02-20 15:41:43 +01:00
|
|
|
|
} else if (!cachedBuffer) {
|
|
|
|
|
|
// No fresh buffer and no cache — clear any stale content
|
|
|
|
|
|
this.terminal.clear();
|
|
|
|
|
|
this.terminal.reset();
|
2026-02-02 05:14:18 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 12:32:33 +01:00
|
|
|
|
// Buffer load complete — unblock live SSE writes (queued events are discarded
|
|
|
|
|
|
// to prevent duplicate content). chunkedTerminalWrite calls _finishBufferLoad
|
|
|
|
|
|
// internally, but if we skipped the write (cache hit or empty), call it here.
|
2026-03-05 17:52:28 +01:00
|
|
|
|
if (this._isLoadingBuffer) {
|
|
|
|
|
|
this._finishBufferLoad();
|
|
|
|
|
|
}
|
|
|
|
|
|
// Drop the guard so user input clears state normally
|
2026-02-22 02:49:19 +01:00
|
|
|
|
this._restoringFlushedState = false;
|
|
|
|
|
|
|
2026-02-22 02:05:49 +01:00
|
|
|
|
// Restore flushed offset and text for this session so the overlay positions
|
|
|
|
|
|
// correctly even before the PTY echo arrives in the terminal buffer.
|
2026-02-21 14:00:16 +01:00
|
|
|
|
if (this._flushedOffsets?.has(sessionId) && this._localEchoOverlay) {
|
2026-02-22 11:41:44 +01:00
|
|
|
|
this._localEchoOverlay.setFlushed(
|
|
|
|
|
|
this._flushedOffsets.get(sessionId),
|
|
|
|
|
|
this._flushedTexts?.get(sessionId) || '',
|
|
|
|
|
|
false // render=false: buffer just loaded, defer to rerender
|
|
|
|
|
|
);
|
2026-02-22 02:05:49 +01:00
|
|
|
|
// Trigger render after xterm.js finishes processing the buffer data.
|
|
|
|
|
|
// terminal.write('', callback) fires the callback after ALL previously
|
2026-02-22 11:41:44 +01:00
|
|
|
|
// queued writes have been parsed — so findPrompt() can find ❯ in the buffer.
|
|
|
|
|
|
const zl = this._localEchoOverlay;
|
2026-02-22 02:05:49 +01:00
|
|
|
|
this.terminal.write('', () => {
|
2026-02-22 11:41:44 +01:00
|
|
|
|
if (zl.hasPending) zl.rerender();
|
2026-02-22 02:05:49 +01:00
|
|
|
|
});
|
2026-02-19 21:44:03 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 16:59:48 +01:00
|
|
|
|
// Fire-and-forget resize + Ctrl+L to force Ink redraw.
|
|
|
|
|
|
// Tailed buffers accumulate stale CUP-positioned Ink frames that overlap
|
|
|
|
|
|
// in the viewport (e.g. duplicate "bypass permissions" bars). Ctrl+L
|
|
|
|
|
|
// triggers a full Ink redraw which overwrites all stale frame content.
|
|
|
|
|
|
// sendResize may be a no-op if dimensions match, so Ctrl+L is essential.
|
|
|
|
|
|
this.sendResize(sessionId).then(() => {
|
|
|
|
|
|
if (selectGen !== this._selectGeneration) return;
|
|
|
|
|
|
fetch(`/api/sessions/${sessionId}/input`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ input: '\x0c' })
|
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
|
});
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
2026-02-13 21:07:49 +01:00
|
|
|
|
// 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();
|
2026-01-25 03:57:44 +01:00
|
|
|
|
}
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
2026-02-13 21:07:49 +01:00
|
|
|
|
// Update task panel if open
|
|
|
|
|
|
const taskPanel = document.getElementById('taskPanel');
|
|
|
|
|
|
if (taskPanel && taskPanel.classList.contains('open')) {
|
|
|
|
|
|
this.renderTaskPanel();
|
2026-01-27 01:07:02 +01:00
|
|
|
|
}
|
2026-02-13 21:07:49 +01:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-03-04 12:17:38 +01:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-13 21:07:49 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-01-27 01:07:02 +01:00
|
|
|
|
|
2026-03-12 21:02:28 -04:00
|
|
|
|
// Open WebSocket for low-latency terminal I/O (after buffer load completes)
|
|
|
|
|
|
this._connectWs(sessionId);
|
|
|
|
|
|
|
2026-03-05 16:44:17 +01:00
|
|
|
|
_crashDiag.log('FOCUS');
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.terminal.focus();
|
2026-01-24 00:53:47 +01:00
|
|
|
|
this.terminal.scrollToBottom();
|
2026-03-05 16:44:17 +01:00
|
|
|
|
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
|
2026-03-05 12:42:32 +01:00
|
|
|
|
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
|
2026-01-18 07:31:19 +01:00
|
|
|
|
} catch (err) {
|
2026-03-05 17:52:28 +01:00
|
|
|
|
if (this._isLoadingBuffer) this._finishBufferLoad();
|
2026-02-22 02:49:19 +01:00
|
|
|
|
this._restoringFlushedState = false;
|
2026-01-19 00:38:11 +01:00
|
|
|
|
console.error('Failed to load session terminal:', err);
|
2026-01-18 07:31:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 05:26:29 +01:00
|
|
|
|
// 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);
|
2026-02-21 23:37:29 +01:00
|
|
|
|
|
2026-02-21 14:00:16 +01:00
|
|
|
|
this._flushedOffsets?.delete(sessionId);
|
2026-02-22 02:05:49 +01:00
|
|
|
|
this._flushedTexts?.delete(sessionId);
|
2026-02-15 05:26:29 +01:00
|
|
|
|
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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 12:51:35 +01:00
|
|
|
|
async closeSession(sessionId, killMux = true) {
|
2026-01-19 00:38:11 +01:00
|
|
|
|
try {
|
2026-03-01 06:05:26 +01:00
|
|
|
|
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
|
2026-02-15 05:26:29 +01:00
|
|
|
|
this._cleanupSessionData(sessionId);
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
|
|
|
|
|
if (this.activeSessionId === sessionId) {
|
|
|
|
|
|
this.activeSessionId = null;
|
2026-02-26 16:44:34 +01:00
|
|
|
|
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
2026-01-29 12:47:23 +01:00
|
|
|
|
// 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);
|
2026-01-19 00:38:11 +01:00
|
|
|
|
} else {
|
|
|
|
|
|
this.terminal.clear();
|
|
|
|
|
|
this.showWelcome();
|
2026-01-21 22:36:45 +01:00
|
|
|
|
this.renderRalphStatePanel(); // Clear ralph panel when no sessions
|
2026-01-19 00:38:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.renderSessionTabs();
|
2026-01-19 04:47:48 +01:00
|
|
|
|
|
2026-02-18 12:51:35 +01:00
|
|
|
|
if (killMux) {
|
2026-02-12 18:29:49 +01:00
|
|
|
|
this.showToast('Session closed and tmux killed', 'success');
|
2026-01-19 04:47:48 +01:00
|
|
|
|
} else {
|
2026-02-12 18:29:49 +01:00
|
|
|
|
this.showToast('Tab hidden, tmux still running', 'info');
|
2026-01-19 04:47:48 +01:00
|
|
|
|
}
|
2026-01-19 00:38:11 +01:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
this.showToast('Failed to close session', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 02:10:44 +01:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-02-26 15:06:36 +01:00
|
|
|
|
// 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';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 02:10:44 +01:00
|
|
|
|
document.getElementById('closeConfirmModal').classList.add('active');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cancelCloseSession() {
|
|
|
|
|
|
this.pendingCloseSessionId = null;
|
|
|
|
|
|
document.getElementById('closeConfirmModal').classList.remove('active');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 12:51:35 +01:00
|
|
|
|
async confirmCloseSession(killMux = true) {
|
2026-01-19 02:10:44 +01:00
|
|
|
|
const sessionId = this.pendingCloseSessionId;
|
|
|
|
|
|
this.cancelCloseSession();
|
|
|
|
|
|
|
|
|
|
|
|
if (sessionId) {
|
2026-02-18 12:51:35 +01:00
|
|
|
|
await this.closeSession(sessionId, killMux);
|
2026-01-19 02:10:44 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 00:38:11 +01:00
|
|
|
|
nextSession() {
|
2026-01-29 12:47:23 +01:00
|
|
|
|
if (this.sessionOrder.length <= 1) return;
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
2026-01-29 12:47:23 +01:00
|
|
|
|
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
|
|
|
|
|
|
const nextIndex = (currentIndex + 1) % this.sessionOrder.length;
|
|
|
|
|
|
this.selectSession(this.sessionOrder[nextIndex]);
|
2026-01-19 00:38:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 14:46:26 +01:00
|
|
|
|
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]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Navigation
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
2026-01-26 23:37:14 +01:00
|
|
|
|
|
|
|
|
|
|
goHome() {
|
|
|
|
|
|
// Deselect active session and show welcome screen
|
|
|
|
|
|
this.activeSessionId = null;
|
2026-02-26 16:44:34 +01:00
|
|
|
|
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
2026-01-26 23:37:14 +01:00
|
|
|
|
this.terminal.clear();
|
|
|
|
|
|
this.showWelcome();
|
|
|
|
|
|
this.renderSessionTabs();
|
|
|
|
|
|
this.renderRalphStatePanel();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Ralph Loop Wizard (methods in ralph-wizard.js)
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
2026-01-26 23:37:14 +01:00
|
|
|
|
|
2026-02-27 10:58:10 +01:00
|
|
|
|
// Wizard state (initialized here, methods loaded from ralph-wizard.js)
|
2026-01-26 23:37:14 +01:00
|
|
|
|
ralphWizardStep = 1;
|
|
|
|
|
|
ralphWizardConfig = {
|
|
|
|
|
|
taskDescription: '',
|
|
|
|
|
|
completionPhrase: 'COMPLETE',
|
|
|
|
|
|
maxIterations: 10,
|
|
|
|
|
|
caseName: 'testcase',
|
2026-02-27 10:58:10 +01:00
|
|
|
|
enableRespawn: false,
|
|
|
|
|
|
generatedPlan: null,
|
2026-01-27 01:23:12 +01:00
|
|
|
|
planGenerated: false,
|
|
|
|
|
|
skipPlanGeneration: false,
|
2026-02-27 10:58:10 +01:00
|
|
|
|
planDetailLevel: 'detailed',
|
|
|
|
|
|
existingPlan: null,
|
|
|
|
|
|
useExistingPlan: false,
|
2026-01-26 23:37:14 +01:00
|
|
|
|
};
|
2026-01-27 01:59:35 +01:00
|
|
|
|
planLoadingTimer = null;
|
|
|
|
|
|
planLoadingStartTime = null;
|
2026-01-26 23:37:14 +01:00
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Kill Sessions
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-01-18 07:31:19 +01:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-01 06:05:26 +01:00
|
|
|
|
await this._apiDelete('/api/sessions');
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.sessions.clear();
|
|
|
|
|
|
this.terminalBuffers.clear();
|
2026-02-13 21:07:49 +01:00
|
|
|
|
this.terminalBufferCache.clear();
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.activeSessionId = null;
|
2026-02-26 16:44:34 +01:00
|
|
|
|
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.respawnStatus = {};
|
2026-01-25 03:57:44 +01:00
|
|
|
|
this.respawnCountdownTimers = {};
|
|
|
|
|
|
this.respawnActionLogs = {};
|
|
|
|
|
|
this.stopCountdownInterval();
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.hideRespawnBanner();
|
|
|
|
|
|
this.renderSessionTabs();
|
|
|
|
|
|
this.terminal.clear();
|
|
|
|
|
|
this.showWelcome();
|
|
|
|
|
|
this.showToast('All sessions killed', 'success');
|
2026-01-18 07:31:19 +01:00
|
|
|
|
} catch (err) {
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.showToast('Failed to kill sessions', 'error');
|
2026-01-18 07:31:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Timer
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
showTimer() {
|
2026-01-19 00:38:11 +01:00
|
|
|
|
document.getElementById('timerBanner').style.display = 'flex';
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this.updateTimer();
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.timerInterval = setInterval(() => this.updateTimer(), 1000);
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
hideTimer() {
|
|
|
|
|
|
document.getElementById('timerBanner').style.display = 'none';
|
2026-01-19 00:38:11 +01:00
|
|
|
|
if (this.timerInterval) {
|
|
|
|
|
|
clearInterval(this.timerInterval);
|
|
|
|
|
|
this.timerInterval = null;
|
|
|
|
|
|
}
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateTimer() {
|
2026-01-19 00:38:11 +01:00
|
|
|
|
if (!this.currentRun || this.currentRun.status !== 'running') return;
|
2026-01-18 05:14:34 +01:00
|
|
|
|
|
|
|
|
|
|
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}%`;
|
2026-01-19 00:38:11 +01:00
|
|
|
|
document.getElementById('timerMeta').textContent =
|
|
|
|
|
|
`${this.currentRun.completedTasks} tasks | $${this.currentRun.totalCost.toFixed(2)}`;
|
2026-01-18 22:04:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 00:38:11 +01:00
|
|
|
|
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');
|
2026-01-18 22:04:02 +01:00
|
|
|
|
}
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
formatTime(ms) {
|
2026-01-19 00:38:11 +01:00
|
|
|
|
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')}`;
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Tokens
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
2026-01-18 22:29:23 +01:00
|
|
|
|
|
2026-01-19 00:38:11 +01:00
|
|
|
|
updateCost() {
|
2026-01-19 01:26:38 +01:00
|
|
|
|
// Now updates tokens instead of cost
|
|
|
|
|
|
this.updateTokens();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateTokens() {
|
2026-02-13 21:07:49 +01:00
|
|
|
|
// Debounce at 200ms — token display is non-critical and shouldn't
|
|
|
|
|
|
// compete with input handling on the main thread
|
2026-03-25 23:32:28 +01:00
|
|
|
|
this._clearTimer('_updateTokensTimeout');
|
2026-02-13 21:07:49 +01:00
|
|
|
|
this._updateTokensTimeout = setTimeout(() => {
|
|
|
|
|
|
this._updateTokensTimeout = null;
|
|
|
|
|
|
this._updateTokensImmediate();
|
|
|
|
|
|
}, 200);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_updateTokensImmediate() {
|
2026-01-25 07:26:49 +01:00
|
|
|
|
// Use global stats if available (includes deleted sessions)
|
2026-01-25 20:12:16 +01:00
|
|
|
|
let totalInput = 0;
|
|
|
|
|
|
let totalOutput = 0;
|
2026-01-25 07:26:49 +01:00
|
|
|
|
if (this.globalStats) {
|
2026-01-25 20:12:16 +01:00
|
|
|
|
totalInput = this.globalStats.totalInputTokens || 0;
|
|
|
|
|
|
totalOutput = this.globalStats.totalOutputTokens || 0;
|
2026-01-25 07:26:49 +01:00
|
|
|
|
} else {
|
|
|
|
|
|
// Fallback to active sessions only
|
|
|
|
|
|
this.sessions.forEach(s => {
|
|
|
|
|
|
if (s.tokens) {
|
2026-01-25 20:12:16 +01:00
|
|
|
|
totalInput += s.tokens.input || 0;
|
|
|
|
|
|
totalOutput += s.tokens.output || 0;
|
2026-01-25 07:26:49 +01:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-01-25 20:12:16 +01:00
|
|
|
|
const total = totalInput + totalOutput;
|
2026-01-19 01:26:38 +01:00
|
|
|
|
this.totalTokens = total;
|
2026-01-25 20:12:16 +01:00
|
|
|
|
const display = this.formatTokens(total);
|
2026-01-25 07:26:49 +01:00
|
|
|
|
|
2026-01-25 20:12:16 +01:00
|
|
|
|
// Estimate cost from tokens (more accurate than stored cost in interactive mode)
|
|
|
|
|
|
const estimatedCost = this.estimateCost(totalInput, totalOutput);
|
2026-01-25 07:26:49 +01:00
|
|
|
|
const tokenEl = this.$('headerTokens');
|
|
|
|
|
|
if (tokenEl) {
|
2026-01-30 17:20:53 +01:00
|
|
|
|
const settings = this.loadAppSettingsFromStorage();
|
|
|
|
|
|
const showCost = settings.showCost ?? false;
|
|
|
|
|
|
tokenEl.textContent = total > 0
|
|
|
|
|
|
? (showCost ? `${display} tokens · $${estimatedCost.toFixed(2)}` : `${display} tokens`)
|
|
|
|
|
|
: '0 tokens';
|
2026-01-25 07:26:49 +01:00
|
|
|
|
tokenEl.title = this.globalStats
|
2026-01-30 17:20:53 +01:00
|
|
|
|
? `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' : ''}`;
|
2026-01-25 07:26:49 +01:00
|
|
|
|
}
|
2026-01-19 01:26:38 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:40:18 +01:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Module Init — localStorage migration and app start
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-02-26 16:44:34 +01:00
|
|
|
|
// 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 {}
|
|
|
|
|
|
|
2026-03-14 21:24:22 +01:00
|
|
|
|
// Initialize — use DOMContentLoaded to ensure all defer'd mixin modules
|
|
|
|
|
|
// (terminal-ui.js, settings-ui.js, etc.) have executed their Object.assign
|
|
|
|
|
|
// onto CodemanApp.prototype before we instantiate.
|
|
|
|
|
|
let app;
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
|
|
app = new CodemanApp();
|
|
|
|
|
|
window.app = app;
|
|
|
|
|
|
});
|
2026-01-31 04:44:46 +01:00
|
|
|
|
window.MobileDetection = MobileDetection;
|