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 ,
2026-07-06 16:15:26 +08:00
// Per-page-load id: the server keys beacons by it, so a reload (fresh id)
// archives the previous page's trail instead of overwriting it, and
// concurrent clients (desktop + phone) don't clobber each other.
_pageId : Date . now (). toString ( 36 ) + '-' + Math . random (). toString ( 36 ). slice ( 2 , 8 ),
2026-03-05 16:44:17 +01:00
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 {}
}
};
2026-07-06 16:15:26 +08:00
// Log previous crash breadcrumbs on startup, and re-beacon them under a
// distinct page id — an iOS PWA reload wipes the in-memory trail, and without
// this the fresh page's first beacon would be all the server ever sees.
2026-03-05 16:44:17 +01:00
try {
const prev = localStorage . getItem ( 'codeman-crash-diag' );
2026-07-06 16:15:26 +08:00
if ( prev ) {
console . log ( '[CRASH-DIAG] Previous session breadcrumbs:\n' + prev );
navigator . sendBeacon ( '/api/crash-diag' , JSON . stringify ({ data : prev , id : _crashDiag . _pageId + '-prev' }));
}
2026-03-05 16:44:17 +01:00
} catch {}
_crashDiag . log ( 'PAGE LOAD' );
// Heartbeat: send breadcrumbs to server every 2s so they survive tab freezes.
2026-07-06 16:15:26 +08:00
function _crashDiagBeacon () {
2026-03-05 16:44:17 +01:00
try {
if ( _crashDiag . _entries . length > 0 ) {
2026-07-06 16:15:26 +08:00
navigator . sendBeacon ( '/api/crash-diag' , JSON . stringify ({ data : _crashDiag . _entries . join ( '\n' ), id : _crashDiag . _pageId }));
2026-03-05 16:44:17 +01:00
}
} catch {}
2026-07-06 16:15:26 +08:00
}
setInterval (() => {
try { localStorage . setItem ( 'codeman-crash-heartbeat' , String ( Date . now ())); } catch {}
_crashDiagBeacon ();
2026-03-05 16:44:17 +01:00
}, 2000 );
2026-07-06 16:15:26 +08:00
// iOS suspends JS the instant the app is backgrounded — entries logged since
// the last 2s tick would sit unsent until (if ever) the page resumes. Flush
// immediately on hide so a repro right before an app switch is never lost.
document . addEventListener ( 'visibilitychange' , () => {
if ( document . visibilityState === 'hidden' ) _crashDiagBeacon ();
});
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' ],
2026-06-10 20:41:34 +02:00
[ SSE_EVENTS . SESSION_LIMIT_PAUSE_SCHEDULED , '_onSessionLimitPauseScheduled' ],
[ SSE_EVENTS . SESSION_LIMIT_RESUME , '_onSessionLimitResume' ],
[ SSE_EVENTS . SESSION_LIMIT_RESUME_CANCELLED , '_onSessionLimitResumeCancelled' ],
2026-06-16 08:53:29 -04:00
[ SSE_EVENTS . SESSION_RESPAWN_BREAKER_TRIPPED , '_onSessionRespawnBreakerTripped' ],
2026-03-03 22:40:18 +01:00
[ SSE_EVENTS . SESSION_CLI_INFO , '_onSessionCliInfo' ],
2026-06-14 05:00:28 +02:00
[ SSE_EVENTS . SESSION_STATUS_TELEMETRY , '_onSessionStatusTelemetry' ],
2026-03-03 22:40:18 +01:00
// Scheduled runs
[ SSE_EVENTS . SCHEDULED_CREATED , '_onScheduledCreated' ],
[ SSE_EVENTS . SCHEDULED_UPDATED , '_onScheduledUpdated' ],
[ SSE_EVENTS . SCHEDULED_COMPLETED , '_onScheduledCompleted' ],
[ SSE_EVENTS . SCHEDULED_STOPPED , '_onScheduledStopped' ],
2026-06-27 08:15:36 +05:30
// Scheduled jobs (cron-style scheduler)
2026-06-29 11:35:56 +05:30
[ SSE_EVENTS . CRON_JOBS_CHANGED , '_onCronJobsChanged' ],
[ SSE_EVENTS . CRON_JOB_DELETED , '_onCronJobsChanged' ],
[ SSE_EVENTS . CRON_RUN_CREATED , '_onCronRunChanged' ],
[ SSE_EVENTS . CRON_RUN_UPDATED , '_onCronRunChanged' ],
2026-06-27 08:15:36 +05:30
2026-03-03 22:40:18 +01:00
// 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' ],
2026-06-13 14:26:06 -04:00
// Remote auto-reconnect (COD-108)
[ SSE_EVENTS . REMOTE_SESSION_RECONNECTED , '_onRemoteSessionReconnected' ],
[ SSE_EVENTS . REMOTE_RECONNECT_EXHAUSTED , '_onRemoteReconnectExhausted' ],
2026-03-03 22:40:18 +01:00
// 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' ],
2026-06-15 08:40:05 +02:00
// Workflow runs (ultracode)
[ SSE_EVENTS . WORKFLOW_RUN_DISCOVERED , '_onWorkflowRunDiscovered' ],
[ SSE_EVENTS . WORKFLOW_RUN_UPDATED , '_onWorkflowRunUpdated' ],
[ SSE_EVENTS . WORKFLOW_RUN_REMOVED , '_onWorkflowRunRemoved' ],
2026-03-03 22:40:18 +01:00
// Images
[ SSE_EVENTS . IMAGE_DETECTED , '_onImageDetected' ],
2026-06-12 09:09:14 -04:00
[ SSE_EVENTS . ATTACHMENT_DETECTED , '_onAttachmentDetected' ],
2026-03-03 22:40:18 +01:00
// Tunnel
[ SSE_EVENTS . TUNNEL_STARTED , '_onTunnelStarted' ],
[ SSE_EVENTS . TUNNEL_STOPPED , '_onTunnelStopped' ],
[ SSE_EVENTS . TUNNEL_PROGRESS , '_onTunnelProgress' ],
[ SSE_EVENTS . TUNNEL_ERROR , '_onTunnelError' ],
[ SSE_EVENTS . TUNNEL_QR_ROTATED , '_onTunnelQrRotated' ],
[ SSE_EVENTS . TUNNEL_QR_REGENERATED , '_onTunnelQrRegenerated' ],
[ SSE_EVENTS . TUNNEL_QR_AUTH_USED , '_onTunnelQrAuthUsed' ],
// Plan orchestration
[ SSE_EVENTS . PLAN_SUBAGENT , '_onPlanSubagent' ],
[ SSE_EVENTS . PLAN_PROGRESS , '_onPlanProgress' ],
[ SSE_EVENTS . PLAN_STARTED , '_onPlanStarted' ],
[ SSE_EVENTS . PLAN_CANCELLED , '_onPlanCancelled' ],
[ SSE_EVENTS . PLAN_COMPLETED , '_onPlanCompleted' ],
2026-03-21 07:20:18 +01:00
// Orchestrator loop
[ SSE_EVENTS . ORCHESTRATOR_STATE_CHANGED , '_onOrchestratorStateChanged' ],
2026-03-21 20:03:24 +01:00
[ SSE_EVENTS . ORCHESTRATOR_PLAN_PROGRESS , '_onOrchestratorPlanProgress' ],
2026-03-21 07:20:18 +01:00
[ SSE_EVENTS . ORCHESTRATOR_PLAN_READY , '_onOrchestratorPlanReady' ],
[ SSE_EVENTS . ORCHESTRATOR_PHASE_STARTED , '_onOrchestratorPhaseStarted' ],
[ SSE_EVENTS . ORCHESTRATOR_PHASE_COMPLETED , '_onOrchestratorPhaseCompleted' ],
[ SSE_EVENTS . ORCHESTRATOR_PHASE_FAILED , '_onOrchestratorPhaseFailed' ],
[ SSE_EVENTS . ORCHESTRATOR_VERIFICATION , '_onOrchestratorVerification' ],
[ SSE_EVENTS . ORCHESTRATOR_TASK_ASSIGNED , '_onOrchestratorTaskAssigned' ],
[ SSE_EVENTS . ORCHESTRATOR_TASK_COMPLETED , '_onOrchestratorTaskCompleted' ],
[ SSE_EVENTS . ORCHESTRATOR_TASK_FAILED , '_onOrchestratorTaskFailed' ],
[ SSE_EVENTS . ORCHESTRATOR_COMPLETED , '_onOrchestratorCompleted' ],
[ SSE_EVENTS . ORCHESTRATOR_ERROR , '_onOrchestratorError' ],
2026-04-12 10:07:15 -04:00
// Clipboard
[ SSE_EVENTS . CLIPBOARD_WRITE , '_onClipboardWrite' ],
2026-06-19 18:42:39 -04:00
// Session order (global tab order sync, COD-131)
[ SSE_EVENTS . SESSION_ORDER_CHANGED , '_onSessionOrderChanged' ],
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-07-01 21:19:20 +00:00
const DEFAULT_SHORTCUTS = [
{
id : 'show-shortcuts' ,
group : 'Panels' ,
label : 'Show Shortcuts' ,
bindings : [
{ modifiers : [ 'ctrl' ], key : '?' , code : 'Slash' },
2026-07-12 19:06:12 +02:00
{ modifiers : [ 'ctrl' , 'shift' ], key : '?' },
2026-07-01 21:19:20 +00:00
{ modifiers : [ 'alt' ], key : '?' , code : 'Slash' },
],
action : 'showShortcutOverlay' ,
},
{
id : 'close-session' ,
group : 'Session' ,
label : 'Close Session' ,
bindings : [{ modifiers : [ 'ctrl' ], key : 'w' }],
action : 'killActiveSession' ,
},
{
id : 'next-session' ,
group : 'Session' ,
label : 'Next Session' ,
bindings : [{ modifiers : [ 'ctrl' ], key : 'Tab' }],
action : 'nextSession' ,
},
{
id : 'clear-terminal' ,
group : 'Terminal' ,
label : 'Clear Terminal' ,
bindings : [{ modifiers : [ 'ctrl' ], key : 'l' }],
action : 'clearTerminal' ,
},
{
id : 'increase-font' ,
group : 'Terminal' ,
label : 'Increase Font' ,
bindings : [
{ modifiers : [ 'ctrl' ], key : '=' , code : 'Equal' },
{ modifiers : [ 'ctrl' ], key : '+' , code : 'Equal' },
],
action : 'increaseFontSize' ,
},
{
id : 'decrease-font' ,
group : 'Terminal' ,
label : 'Decrease Font' ,
bindings : [{ modifiers : [ 'ctrl' ], key : '-' , code : 'Minus' }],
action : 'decreaseFontSize' ,
},
{
id : 'voice-input' ,
group : 'Terminal' ,
label : 'Voice Input' ,
bindings : [{ modifiers : [ 'ctrl' , 'shift' ], key : 'V' }],
action : 'toggleVoiceInput' ,
},
2026-07-12 19:06:12 +02:00
{
id : 'restore-terminal-size' ,
group : 'Terminal' ,
label : 'Restore Terminal Size' ,
bindings : [{ modifiers : [ 'ctrl' , 'shift' ], key : 'R' }],
action : 'restoreTerminalSize' ,
},
2026-07-01 21:19:20 +00:00
{
id : 'move-tab-left' ,
group : 'Tabs' ,
label : 'Move Active Tab Left' ,
bindings : [{ modifiers : [ 'ctrl' , 'shift' ], key : '{' , code : 'BracketLeft' }],
action : 'moveActiveTabLeft' ,
},
{
id : 'move-tab-right' ,
group : 'Tabs' ,
label : 'Move Active Tab Right' ,
bindings : [{ modifiers : [ 'ctrl' , 'shift' ], key : '}' , code : 'BracketRight' }],
action : 'moveActiveTabRight' ,
},
{
id : 'command-palette' ,
group : 'Session' ,
label : 'Find Open Session' ,
bindings : [
{ modifiers : [ 'ctrl' ], key : 'k' , code : 'KeyK' },
{ modifiers : [ 'meta' ], key : 'k' , code : 'KeyK' },
{ modifiers : [ 'alt' ], key : 'k' , code : 'KeyK' },
],
action : 'openCommandPalette' ,
},
{
id : 'previous-next-session' ,
group : 'Session' ,
label : 'Previous / Next Session' ,
displayBindings : [ 'Alt/Option+[' , 'Alt/Option+]' ],
},
{
id : 'switch-tab-n' ,
group : 'Session' ,
label : 'Switch to Tab N' ,
displayBindings : [ 'Alt/Option+1-9' ],
},
{
id : 'focus-tabs' ,
group : 'Tabs' ,
label : 'Focus Tabs' ,
displayBindings : [ 'ArrowLeft' , 'ArrowRight' , 'Home' , 'End' ],
},
{
id : 'activate-focused-tab' ,
group : 'Tabs' ,
label : 'Activate Focused Tab' ,
displayBindings : [ 'Enter' , 'Space' ],
},
{
id : 'insert-newline' ,
group : 'Terminal' ,
label : 'Insert Newline' ,
displayBindings : [ 'Shift+Enter' , 'Ctrl+Enter' ],
},
{
id : 'close-panels' ,
group : 'Panels' ,
label : 'Close Panels' ,
displayBindings : [ 'Escape' ],
},
];
2026-03-03 22:40:18 +01:00
// ═══════════════════════════════════════════════════════════════
// CodemanApp Class — constructor and global state
// ═══════════════════════════════════════════════════════════════
2026-02-26 16:44:34 +01:00
class CodemanApp {
2026-01-18 05:14:34 +01:00
constructor () {
this . sessions = new Map ();
2026-03-03 22:56:40 +01:00
this . _shortIdCache = new Map (); // Cache session ID .slice(0, 8) results
2026-01-29 12:47:23 +01:00
this . sessionOrder = []; // Track tab order for drag-and-drop reordering
this . draggedTabId = null ; // Currently dragged tab session ID
2026-01-18 07:31:19 +01:00
this . cases = [];
2026-01-18 05:14:34 +01:00
this . currentRun = null ;
2026-01-19 01:26:38 +01:00
this . totalTokens = 0 ;
2026-01-25 07:26:49 +01:00
this . globalStats = null ; // Global token/cost stats across all sessions
2026-01-18 05:14:34 +01:00
this . eventSource = null ;
2026-05-16 23:56:53 -04:00
// Stable per-page client ID — lets the server target this connection
// for live filter updates (POST /api/events/subscribe) without forcing
// an SSE reconnect on session switches.
this . _clientId = ( typeof crypto !== 'undefined' && crypto . randomUUID )
? crypto . randomUUID ()
: 'c-' + Math . random (). toString ( 36 ). slice ( 2 ) + Date . now (). toString ( 36 );
2026-07-12 18:42:36 +02:00
// Per-TAB nonce for the WS registry key (COD-137). _loadReliableState()
// later replaces _clientId with the browser-wide localStorage identity
// (shared by every tab/window of this profile), so the WS upgrade sends
// `clientId:nonce` instead — a same-tab reconnect still supersedes its own
// socket, but two tabs on one session coexist instead of evicting each
// other in a 4010 ping-pong. Input frames keep the bare clientId for seq
// dedup.
this . _wsTabNonce = this . _clientId ;
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-06-06 03:53:54 +02:00
// ── Session detach / undock (beta) ───────────────────────────────────
// A "solo window" is a popped-out browser window showing exactly one
// session. Detected from the /session/:id URL path (robust even if a cached
// service-worker shell loads), with the server-injected global as a fallback.
this . soloSessionId = this . _detectSoloSessionId ();
this . isSoloWindow = !! this . soloSessionId ;
this . detachedSessions = new Set (); // dashboard-side: ids currently popped out
this . detachedWindows = new Map (); // dashboard-side: id -> WindowProxy
this . _detachWatchTimers = new Map (); // dashboard-side: id -> setInterval handle
this . windowChannel = null ; // BroadcastChannel for cross-window sync
2026-06-06 04:14:40 +02:00
this . _redockGrace = new Map (); // id -> timer: deferred redock (debounces popup reloads)
this . _detachPingPending = null ; // Set of ids awaiting a liveness answer
this . _detachLivenessTimer = null ; // periodic reconcile of channel-only detached windows
2026-06-08 15:41:46 +02:00
this . _detachOrphanStrikes = new Map (); // id -> consecutive unanswered roll-calls (redock at 2)
2026-06-06 03:53:54 +02:00
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-07-12 18:33:18 +02:00
this . _initialFullBufferLoad = true ; // first buffer load after a page load fetches full tmux scrollback (COD-47)
2026-06-09 10:38:35 -04:00
this . terminalLoadStates = new Map (); // Map<sessionId, { generation, phase }>
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 ;
2026-06-15 08:40:05 +02:00
// Ultracode / Workflow run visualization (master-detail tab)
this . workflowRuns = new Map (); // runId -> run summary (LEFT list)
this . workflowRunDetails = new Map (); // runId -> full run with agents[] (RIGHT pane)
this . activeWorkflowRunId = null ;
this . activeWorkflowPhaseIndex = null ;
2026-06-15 16:33:54 +02:00
// Ultracode floating run windows (additional to the dock panel — ultracode-windows.js)
this . ultracodeWindows = new Map (); // runId -> { element, parentSessionId, dragListeners, collapsed }
this . ultracodeWindowsClosed = new Set (); // runIds the user explicitly dismissed (don't re-pop)
this . ultracodeWindowCloseTimers = new Map (); // runId -> auto-close timeout
this . ultracodeWindowZIndex = 1000 ;
2026-01-25 14:33:12 +01:00
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-06-12 09:09:14 -04:00
this . attachmentCards = new Map (); // Map<attachmentId, { element, sessionId, filePath }>
this . attachmentCardStack = null ;
2026-06-12 14:37:27 -04:00
this . attachmentHistoryCounts = new Map (); // Map<sessionId, count>
this . attachmentHistoryItems = [];
this . attachmentHistoryDrawerOpen = false ;
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-06-19 21:32:42 -04:00
this . _wsState = 'disconnected' ; // connecting | connected | reconnecting | fallback | disconnected
this . _wsLastRecvAt = 0 ; // ms timestamp of the last frame received on the active WS
2026-03-12 21:02:28 -04:00
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-06-09 10:38:35 -04:00
this . _bufferLoadSeq = 0 ;
this . _bufferLoadOwner = null ;
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-06-19 16:44:18 +02:00
// Reliable, durable input delivery (replaces the old best-effort queue).
// Every input byte is recorded with a stable clientId + a monotonic
// per-session seq, persisted to localStorage, and only dropped once the
// server ACKs that exact seq — so a half-open socket silently dropping a
// frame, a reconnect, or a page reload can never lose a typed prompt.
// Exactly-once: the server applies each (clientId, seq) at most once.
2026-02-12 08:34:51 +01:00
this . _connectionStatus = 'connected' ;
2026-06-19 16:44:18 +02:00
this . _clientId = '' ;
this . _seqCounters = new Map (); // sessionId -> last issued seq
this . _pendingDeliveries = new Map (); // sessionId -> [{seq,data,useMux,ts,tries,sentAt}]
2026-06-20 10:03:25 -04:00
// Last rendered connection-indicator tuple; the hot input path skips DOM
// writes when the freshly computed descriptor is identical (COD-136).
this . _lastIndicatorDescriptor = null ;
2026-06-19 16:44:18 +02:00
this . _postDraining = new Set (); // sessionIds with an in-flight POST drainer
this . _persistReliableTimer = null ;
this . _reliableAckTimeoutMs = 4000 ; // unacked WS frame older than this ⇒ socket likely dead
this . _reliableMaxBytes = 256 * 1024 ; // cap on the persisted backlog
this . _loadReliableState ();
this . _reliableSweepTimer = setInterval (() => this . _redeliverSweep (), 2000 );
// Flush the durable queue synchronously when the page is hidden/closed —
// debounced persistence may have a pending write we mustn't lose on reload.
window . addEventListener ( 'pagehide' , () => this . _persistReliableNow ());
document . addEventListener ( 'visibilitychange' , () => {
if ( document . visibilityState === 'hidden' ) this . _persistReliableNow ();
});
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 ) {
2026-06-09 10:38:35 -04:00
if ( this . _isLoadingBuffer ) this . _finishBufferLoad ( selectGen );
2026-03-25 23:32:28 +01:00
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-06-06 03:53:54 +02:00
// Detach/undock: open the cross-window sync channel; if this is a solo
// (popped-out) window, apply its minimal chrome immediately so the tab
// strip never flashes before handleInit selects the target session.
this . _initWindowChannel ();
if ( this . isSoloWindow ) document . body . classList . add ( 'solo-mode' );
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-06-14 05:44:54 +02:00
this . restorePlanUsageChip ();
2026-06-13 19:46:50 +02:00
this . applySkin ();
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
2026-06-10 01:30:43 +02:00
const settingsPromise = fetch ( '/api/settings' ). then ( r => r . ok ? r . json () : null ). then ( env => env ? . data ?? null ). catch (() => null );
2026-02-18 11:28:34 +01:00
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-06-13 19:46:50 +02:00
this . applySkin ();
2026-02-18 10:23:10 +01:00
this . applyTabWrapSettings ();
2026-01-24 02:19:46 +01:00
this . applyMonitorVisibility ();
2026-06-16 14:27:10 +02:00
// ultracodeFloatingWindows syncs from the server (non-display key), but on a
// FRESH device the getLightState run snapshot can seed workflowRuns BEFORE this
// async settings load resolves — so the floating-window gate read false then and
// skipped any already-active run. Re-sync now that the real setting is loaded so
// an in-flight run pops its window immediately instead of waiting for the next
// ~10s SSE tick. Idempotent: open windows are left as-is; if the setting is off
// it tears any premature windows down.
if ( typeof this . syncAllUltracodeFloatingWindows === 'function' ) {
this . syncAllUltracodeFloatingWindows ();
}
2026-01-24 02:19:46 +01:00
});
2026-02-18 11:59:42 +01:00
// Hide loading skeleton now that the app shell is ready
document . body . classList . add ( 'app-loaded' );
2026-01-18 05:14:34 +01:00
}
2026-03-14 19:20:44 +01:00
_initWebGL () {
if ( typeof WebglAddon === 'undefined' ) return ;
try {
this . _webglAddon = new WebglAddon . WebglAddon ();
this . _webglAddon . onContextLoss (() => {
console . error ( '[CRASH-DIAG] WebGL context LOST — falling back to canvas renderer' );
2026-05-16 23:56:30 -04:00
_crashDiag . log ( 'WEBGL_LOST' );
this . _disableWebGLSticky ( 'context-lost' );
2026-05-19 11:43:36 +02:00
this . _disposeWebGLObserver ();
2026-05-16 23:56:30 -04:00
this . _webglAddon ? . dispose ();
2026-03-14 19:20:44 +01:00
this . _webglAddon = null ;
2026-06-09 10:38:35 -04:00
this . _scheduleTerminalRepaint ();
2026-03-14 19:20:44 +01:00
});
this . terminal . loadAddon ( this . _webglAddon );
console . log ( '[CRASH-DIAG] WebGL renderer enabled' );
2026-05-16 23:56:30 -04:00
this . _installWebGLLongTaskGuard ();
2026-03-14 19:20:44 +01:00
} catch ( _e ) { /* WebGL2 unavailable — canvas renderer used */ }
}
2026-05-16 23:56:30 -04:00
/**
* Watch for sustained main-thread stalls that indicate WebGL/GPU trouble.
2026-05-19 11:43:36 +02:00
* After WEBGL_FALLBACK.LONGTASK_COUNT long tasks (>=LONGTASK_MS each) within
* WINDOW_MS, dispose the WebGL addon and persist a sticky disable so
* subsequent reloads also use the DOM renderer. GRACE_MS skips initial-load
* stalls. Force-re-enable: ?webgl=force.
2026-05-16 23:56:30 -04:00
*/
_installWebGLLongTaskGuard () {
if ( typeof PerformanceObserver === 'undefined' || this . _webglLongTaskObserver ) return ;
const installedAt = performance . now ();
const recent = [];
try {
this . _webglLongTaskObserver = new PerformanceObserver (( list ) => {
if ( ! this . _webglAddon ) return ;
const now = performance . now ();
2026-05-19 11:43:36 +02:00
if ( now - installedAt < WEBGL_FALLBACK . GRACE_MS ) return ;
if ( evaluateWebGLLongTaskTrip ( recent , list . getEntries (), now )) {
console . warn ( `[CRASH-DIAG] WebGL long-task threshold ( ${ recent . length } stalls/ ${ WEBGL_FALLBACK . WINDOW_MS } ms) — falling back to canvas renderer` );
2026-05-16 23:56:30 -04:00
_crashDiag . log ( `WEBGL_FALLBACK: ${ recent . length } ` );
this . _disableWebGLSticky ( 'long-tasks' );
2026-05-19 11:43:36 +02:00
this . _disposeWebGLObserver ();
2026-05-16 23:56:30 -04:00
this . _webglAddon ? . dispose ();
this . _webglAddon = null ;
2026-06-09 10:38:35 -04:00
this . _scheduleTerminalRepaint ();
2026-05-16 23:56:30 -04:00
}
});
this . _webglLongTaskObserver . observe ({ type : 'longtask' , buffered : false });
} catch { /* longtask not supported */ }
}
2026-05-19 11:43:36 +02:00
/**
* Disconnect the WebGL longtask observer. Idempotent. Called from the trip
* path, the onContextLoss handler, and any future terminal-teardown path —
* the observer outlives its addon otherwise, holding a closure reference
* over `this` for every long task the page emits.
*/
_disposeWebGLObserver () {
if ( ! this . _webglLongTaskObserver ) return ;
try { this . _webglLongTaskObserver . disconnect (); } catch {}
this . _webglLongTaskObserver = null ;
}
2026-06-10 02:07:26 +02:00
/**
* Repaint the full terminal viewport after a renderer swap (WebGL → canvas/DOM).
* Scheduled on the next frame so it lands after the addon teardown settles, and
* debounced so the context-loss and long-task fallback paths can't double-fire.
* No-ops safely if the terminal isn't ready.
*/
_scheduleTerminalRepaint () {
if ( this . _terminalRepaintScheduled ) return ;
this . _terminalRepaintScheduled = true ;
const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : ( cb ) => setTimeout ( cb , 0 );
raf (() => {
this . _terminalRepaintScheduled = false ;
try { this . terminal ? . refresh ( 0 , this . terminal . rows - 1 ); } catch {}
});
}
2026-05-16 23:56:30 -04:00
_disableWebGLSticky ( reason ) {
try {
localStorage . setItem ( 'codeman-webgl-disabled' , JSON . stringify ({ reason , at : Date . now () }));
} catch {}
}
2026-03-03 22:40:18 +01:00
// ═══════════════════════════════════════════════════════════════
// Event Listeners (Keyboard Shortcuts, Resize, Beforeunload)
// ═══════════════════════════════════════════════════════════════
2026-01-18 07:31:19 +01:00
setupEventListeners () {
2026-07-12 19:06:12 +02:00
// Action name → handler map for the shortcut registry (DEFAULT_SHORTCUTS +
// user overrides from settings.shortcutOverrides, merged by
// getShortcutRegistry()). The command palette chord is deliberately NOT in
// this map — shouldOpenCommandPaletteFromShortcut() dispatches it above with
// focus-target awareness (it must fire from the terminal but not from inputs).
const SHORTCUT_ACTIONS = {
showShortcutOverlay : () => this . showShortcutOverlay (),
killActiveSession : () => this . killActiveSession (),
nextSession : () => this . nextSession (),
clearTerminal : () => this . clearTerminal (),
restoreTerminalSize : () => this . restoreTerminalSize (),
increaseFontSize : () => this . increaseFontSize (),
decreaseFontSize : () => this . decreaseFontSize (),
toggleVoiceInput : () => VoiceInput . toggle (),
moveActiveTabLeft : () => this . moveActiveTabLeft (),
moveActiveTabRight : () => this . moveActiveTabRight (),
};
2026-03-25 23:32:28 +01:00
2026-01-19 01:42:47 +01:00
// Use capture to handle before terminal
2026-01-18 21:42:57 +01:00
document . addEventListener ( 'keydown' , ( e ) => {
2026-03-14 16:23:25 +09:00
// Don't intercept keys during CJK IME composition
if ( e . isComposing || e . keyCode === 229 ) return ;
2026-07-01 10:05:58 +00:00
if ( this . shouldOpenCommandPaletteFromShortcut ? .( e )) {
e . preventDefault ();
this . openCommandPalette ();
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-06-14 09:30:01 +02:00
if ( this . attachmentHistoryDrawerOpen ) this . closeAttachmentHistory ();
2026-06-18 12:28:14 -04:00
this . closeSessionManager ();
2026-07-01 10:05:58 +00:00
this . closeCommandPalette ? .();
2026-07-12 19:06:12 +02:00
this . closeShortcutOverlay ? .();
2026-01-18 22:18:17 +01:00
}
2026-06-14 15:54:50 -04:00
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
// keyboard layouts that emit special characters under Option (Option+1 -> ¡,
// Option+[ -> "“") still switch sessions. e.code is the physical key regardless
// of layout. Option+1-9 = switch by index; Option+[ / Option+] = prev / next.
if ( e . altKey && ! e . ctrlKey && ! e . shiftKey ) {
const code = e . code || '' ;
const digitMatch = code . match ( /^Digit([1-9])$/ );
if ( digitMatch ) {
const idx = parseInt ( digitMatch [ 1 ], 10 ) - 1 ;
if ( idx < this . sessionOrder . length ) {
e . preventDefault ();
this . selectSession ( this . sessionOrder [ idx ]);
}
return ;
}
if ( e . code === 'BracketLeft' ) {
2026-04-11 06:59:50 +02:00
e . preventDefault ();
2026-06-14 15:54:50 -04:00
this . prevSession ();
return ;
}
if ( e . code === 'BracketRight' ) {
e . preventDefault ();
this . nextSession ();
return ;
2026-04-11 06:59:50 +02:00
}
}
2026-07-12 19:06:12 +02:00
// Match against the shortcut registry so user rebinds and per-shortcut
// disables (App Settings → Shortcuts) take effect. Every dispatchable
// binding requires Ctrl/Cmd/Alt (capture enforces the same), so plain
// typing exits early without touching the registry.
if ( ! e . ctrlKey && ! e . metaKey && ! e . altKey ) return ;
for ( const shortcut of this . getShortcutRegistry ()) {
if ( shortcut . disabled || ! shortcut . action ) continue ;
const action = SHORTCUT_ACTIONS [ shortcut . action ];
if ( ! action ) continue ;
if ( this . matchesShortcutEvent ( e , shortcut )) {
2026-03-25 23:32:28 +01:00
e . preventDefault ();
2026-07-12 19:06:12 +02:00
action ();
2026-03-25 23:32:28 +01:00
return ;
}
2026-02-25 00:32:45 +01:00
}
2026-01-19 01:42:47 +01:00
}, true ); // Use capture phase to handle before terminal
2026-01-25 20:36:04 +01:00
2026-02-04 07:24:32 +01:00
// Token stats click handler (with guard to prevent duplicate handlers on reconnect)
2026-01-25 20:36:04 +01:00
const tokenEl = this . $ ( 'headerTokens' );
2026-02-04 07:24:32 +01:00
if ( tokenEl && ! tokenEl . _statsHandlerAttached ) {
2026-01-25 20:36:04 +01:00
tokenEl . classList . add ( 'clickable' );
2026-02-04 07:24:32 +01:00
tokenEl . _statsHandlerAttached = true ;
2026-01-25 20:36:04 +01:00
tokenEl . addEventListener ( 'click' , () => this . openTokenStats ());
}
2026-01-30 13:25:18 +01:00
// Color picker for session customization
this . setupColorPicker ();
2026-01-18 07:31:19 +01:00
}
2026-03-03 22:40:18 +01:00
// ═══════════════════════════════════════════════════════════════
// SSE Connection
// ═══════════════════════════════════════════════════════════════
2026-01-18 07:31:19 +01:00
2026-05-16 23:56:53 -04:00
/**
* POST a live subscription update so the server filters terminal events
* to the given session(s) for this client. Fire-and-forget — failures
* are non-fatal because we'll still get every event we don't want
* (just at higher cost), and the next reconnect carries the filter via
* the SSE query string.
*/
_updateSseSubscription ( sessionId ) {
try {
const body = JSON . stringify ({
clientId : this . _clientId ,
sessions : sessionId ? [ sessionId ] : null ,
});
fetch ( '/api/events/subscribe' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body ,
keepalive : true ,
}). catch (() => { /* non-fatal */ });
} catch { /* non-fatal */ }
}
2026-06-06 03:53:54 +02:00
// ══════════════════════════════════════════════════════════════════════
// Session detach / undock (beta/session-detach)
//
// Each detached window is just another normal client of the same session:
// the server already fans one PTY's output out to N SSE/WS clients and merges
// input from all of them, so a popped-out window is live with no extra server
// plumbing. The dashboard tracks which sessions are out, marks their tabs, and
// re-docks when the window closes. A BroadcastChannel keeps state in sync
// across windows (and survives a dashboard reload via roll-call).
// ══════════════════════════════════════════════════════════════════════
/** Resolve the solo session id from the URL path (preferred) or the
* server-injected global (fallback). Returns null for the normal dashboard. */
_detectSoloSessionId () {
try {
if ( typeof window !== 'undefined' && typeof window . __CODEMAN_SOLO__ === 'string' && window . __CODEMAN_SOLO__ ) {
return window . __CODEMAN_SOLO__ ;
}
const m = location . pathname . match ( /^\/session\/([^/]+)\/?$/ );
return m ? decodeURIComponent ( m [ 1 ]) : null ;
} catch { return null ; }
}
/**
* Pop a session out into its own browser window. SINGLE, idempotent entry
* point: the tab's pop-out icon calls this, and a future gesture layer
* ("pinch to drop") calls the exact same method — so keep it cheap and
* side-effect-light. Calling it again for an already-open window just raises
* that window.
* @param {string} id session id
*/
detachSession ( id ) {
if ( this . isSoloWindow ) return ; // a solo window can't spawn more
if ( ! this . sessions . has ( id )) return ;
2026-06-06 04:14:40 +02:00
// Already detached → raise the existing popup instead of opening (or
// reloading) another. Mirrors the tab-click path: after a dashboard reload
// we hold no WindowProxy ref, so this raises via the channel rather than
// re-running window.open (which would reload the popup's terminal). Returns
// false only when we owned a now-closed window (re-dock + fall through to
// genuinely re-open below).
if ( this . detachedSessions . has ( id ) && this . _raiseDetached ( id )) return ;
2026-06-06 03:53:54 +02:00
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no' ;
let win = null ;
try { win = window . open ( '/session/' + encodeURIComponent ( id ), 'codeman-session-' + id , features ); } catch {}
if ( ! win ) {
this . showToast ? .( 'Pop-out blocked — allow popups for this site to detach a session' , 'error' );
return ;
}
this . detachedWindows . set ( id , win );
this . _markDetached ( id , true );
this . _watchDetachedWindow ( id , win );
this . _postWindowMessage ({ type : 'detached' , id });
try { win . focus (); } catch {}
}
2026-06-06 04:14:40 +02:00
/** Raise the popup for an already-detached session. Returns true if the raise
* was handled (caller should stop); false if we owned a now-closed window and
* re-docked it (caller should fall through to inline / re-open). Unifies the
* pop-out icon and tab-click paths so neither reloads a live popup. */
_raiseDetached ( id ) {
const win = this . detachedWindows . get ( id );
if ( win && ! win . closed ) { try { win . focus (); } catch {} return true ; }
if ( win && win . closed ) { this . _redock ( id ); return false ; } // owned ref dead → redock + fall through
// No local ref (dashboard reloaded): assume alive and raise via the channel.
// A liveness ping (or the popup's own unload) heals the badge if it's gone.
this . _postWindowMessage ({ type : 'focus-request' , id });
return true ;
}
2026-06-06 03:53:54 +02:00
/** Re-dock a session: close its window (which re-docks via its unload
* announcement) and clear dashboard state now. */
redockSession ( id ) {
const win = this . detachedWindows . get ( id );
if ( win && ! win . closed ) { try { win . close (); } catch {} }
this . _postWindowMessage ({ type : 'close-request' , id });
this . _redock ( id );
}
/** Clear all dashboard-side detached state/timers for a session. */
_redock ( id ) {
const t = this . _detachWatchTimers . get ( id );
if ( t ) { clearInterval ( t ); this . _detachWatchTimers . delete ( id ); }
2026-06-06 04:14:40 +02:00
this . _cancelPendingRedock ( id );
2026-06-08 15:41:46 +02:00
this . _detachOrphanStrikes . delete ( id );
2026-06-06 03:53:54 +02:00
this . detachedWindows . delete ( id );
this . _markDetached ( id , false );
}
2026-06-06 04:14:40 +02:00
/** Defer a channel-driven redock briefly. A popup *reload* emits 'redocked'
* then re-announces 'detached'; the grace window lets that re-announce cancel
* the redock, so a reload doesn't blip the dashboard badge. A real close
* leaves the redock unanswered and it fires. */
_scheduleRedock ( id ) {
if ( this . _redockGrace . has ( id )) return ;
const timer = setTimeout (() => { this . _redockGrace . delete ( id ); this . _redock ( id ); }, 1500 );
this . _redockGrace . set ( id , timer );
}
_cancelPendingRedock ( id ) {
const t = this . _redockGrace . get ( id );
if ( t ) { clearTimeout ( t ); this . _redockGrace . delete ( id ); }
}
2026-06-06 03:53:54 +02:00
/** Toggle the "detached" marker on a tab (immediate DOM update + state set).
* Full re-renders re-apply the class from this.detachedSessions. */
_markDetached ( id , on ) {
if ( on ) this . detachedSessions . add ( id ); else this . detachedSessions . delete ( id );
const container = this . $ ( 'sessionTabs' );
const tab = container && container . querySelector ( `.session-tab[data-id=" ${ id } "]` );
if ( tab ) tab . classList . toggle ( 'detached' , on );
}
/** Poll a window we opened; when it closes, re-dock its tab. This is the
* primary (reliable) close-detection path for windows this tab opened. */
_watchDetachedWindow ( id , win ) {
const prev = this . _detachWatchTimers . get ( id );
if ( prev ) clearInterval ( prev );
const timer = setInterval (() => {
if ( ! win || win . closed ) {
clearInterval ( timer );
this . _detachWatchTimers . delete ( id );
this . _redock ( id );
}
}, 800 );
this . _detachWatchTimers . set ( id , timer );
}
/** Open the cross-window BroadcastChannel and wire role-specific handlers. */
_initWindowChannel () {
if ( typeof BroadcastChannel === 'undefined' ) return ;
try { this . windowChannel = new BroadcastChannel ( 'codeman-windows' ); }
catch { this . windowChannel = null ; return ; }
this . windowChannel . onmessage = ( e ) => this . _onWindowMessage ( e . data );
if ( this . isSoloWindow ) {
// Announce presence so the dashboard marks this session's tab detached —
// even if this window was opened directly by URL rather than window.open.
this . _postWindowMessage ({ type : 'detached' , id : this . soloSessionId });
// On close, tell the dashboard to re-dock. pagehide is the reliable signal
// on modern browsers; beforeunload is a belt-and-suspenders fallback.
const announceClose = () => this . _postWindowMessage ({ type : 'redocked' , id : this . soloSessionId });
window . addEventListener ( 'pagehide' , announceClose );
window . addEventListener ( 'beforeunload' , announceClose );
} else {
// Dashboard: ask any already-open solo windows to re-announce themselves
2026-06-06 04:14:40 +02:00
// (covers a dashboard reload while popups remain open), then keep
// reconciling so a popup that died WITHOUT a 'redocked' (hard kill / crash)
// eventually un-marks its tab.
2026-06-06 03:53:54 +02:00
this . _postWindowMessage ({ type : 'roll-call' });
2026-06-06 04:14:40 +02:00
this . _startDetachLiveness ();
2026-06-06 03:53:54 +02:00
}
}
_postWindowMessage ( msg ) {
try { if ( this . windowChannel ) this . windowChannel . postMessage ( msg ); } catch {}
}
_onWindowMessage ( msg ) {
if ( ! msg || typeof msg !== 'object' ) return ;
if ( this . isSoloWindow ) {
// Roll-call has no id (broadcast to all) — answer before the id filter.
if ( msg . type === 'roll-call' ) { this . _postWindowMessage ({ type : 'detached' , id : this . soloSessionId }); return ; }
if ( msg . id !== this . soloSessionId ) return ;
if ( msg . type === 'close-request' ) { try { window . close (); } catch {} }
else if ( msg . type === 'focus-request' ) { try { window . focus (); } catch {} }
return ;
}
// Dashboard side.
if ( msg . type === 'detached' && msg . id ) {
2026-06-06 04:14:40 +02:00
this . _cancelPendingRedock ( msg . id ); // a re-announce (e.g. popup reload) cancels a deferred redock
this . _detachPingPending ? . delete ( msg . id ); // and proves liveness for this tick
2026-06-08 15:41:46 +02:00
this . _detachOrphanStrikes . delete ( msg . id ); // any answer clears accumulated misses
2026-06-06 03:53:54 +02:00
this . _markDetached ( msg . id , true );
} else if ( msg . type === 'redocked' && msg . id ) {
2026-06-06 04:14:40 +02:00
this . _scheduleRedock ( msg . id ); // defer: a popup reload fires redocked→detached; grace avoids a badge blip
2026-06-06 03:53:54 +02:00
} else if ( msg . type === 'detach-request' && msg . id ) {
// Future gesture hook: another window asks the dashboard to detach a tab.
this . detachSession ( msg . id );
}
}
2026-06-06 04:14:40 +02:00
/** Dashboard: periodically reconcile detached tabs we hold no window ref for
* (e.g. after a dashboard reload). Owned windows are covered by the
* win.closed poll; channel-only ones can only be checked by asking them to
* re-announce and re-docking any that stay silent. */
_startDetachLiveness () {
if ( this . _detachLivenessTimer ) return ;
this . _detachLivenessTimer = setInterval (() => this . _pingDetached (), 5000 );
}
_pingDetached () {
const orphans = [];
for ( const id of this . detachedSessions ) {
const win = this . detachedWindows . get ( id );
if ( ! win ) orphans . push ( id ); // channel-only — must verify via re-announce
else if ( win . closed ) this . _redock ( id ); // owned & closed — heal now
}
if ( ! orphans . length ) return ;
this . _detachPingPending = new Set ( orphans );
this . _postWindowMessage ({ type : 'roll-call' });
2026-06-08 15:41:46 +02:00
// Live popups answer 'detached' (clearing themselves above); survivors stay in
// the pending set. Redock only after TWO consecutive unanswered roll-calls — a
// backgrounded popup is timer-throttled and may miss a single 1.2s window, and
// we don't want to wrongly un-mark a still-open tab. A later answer resets the
// strike count (see _onWindowMessage).
2026-06-06 04:14:40 +02:00
setTimeout (() => {
if ( ! this . _detachPingPending ) return ;
2026-06-08 15:41:46 +02:00
for ( const id of this . _detachPingPending ) {
const strikes = ( this . _detachOrphanStrikes . get ( id ) || 0 ) + 1 ;
if ( strikes >= 2 ) { this . _detachOrphanStrikes . delete ( id ); this . _redock ( id ); }
else this . _detachOrphanStrikes . set ( id , strikes );
}
2026-06-06 04:14:40 +02:00
this . _detachPingPending = null ;
}, 1200 );
}
2026-06-06 03:53:54 +02:00
/** Solo window: select the target session and apply minimal single-session
* chrome. Called from handleInit once the session list has loaded. */
_applySoloMode () {
document . body . classList . add ( 'solo-mode' );
const session = this . sessions . get ( this . soloSessionId );
if ( ! session ) { this . _showSoloSessionGone (); return ; }
// Force re-select (handleInit cleared terminal state above).
this . activeSessionId = null ;
this . selectSession ( this . soloSessionId );
const name = this . getSessionName ( session ) || 'Session' ;
const titleEl = document . getElementById ( 'soloSessionTitle' );
if ( titleEl ) { titleEl . textContent = name ; titleEl . style . display = '' ; }
const redock = document . getElementById ( 'soloRedockBtn' );
if ( redock ) redock . style . display = '' ;
document . title = name + ' — Codeman' ;
if ( this . notificationManager ) this . notificationManager . originalTitle = document . title ;
// Neutralize the dashboard-only brand click in a solo window.
const logo = document . querySelector ( '.header-brand .logo' );
if ( logo ) logo . onclick = ( e ) => { e . preventDefault (); };
}
/** Solo window: the target session is gone (never existed, or ended while
* this window was open). Show a friendly terminal state. */
_showSoloSessionGone () {
document . body . classList . add ( 'solo-mode' );
if ( document . querySelector ( '.solo-gone-overlay' )) return ;
const el = document . createElement ( 'div' );
el . className = 'solo-gone-overlay' ;
el . innerHTML = '<h2>Session unavailable</h2>'
+ '<p>This session has ended or is no longer available.</p>'
+ '<button class="btn-primary" onclick="window.close()">Close window</button>' ;
document . body . appendChild ( el );
document . title = 'Session ended — Codeman' ;
}
2026-01-18 05:14:34 +01:00
connectSSE () {
2026-01-30 13:25:18 +01:00
// Check if browser is offline
if ( ! navigator . onLine ) {
this . setConnectionStatus ( 'offline' );
return ;
}
2026-01-27 17:46:43 +01:00
// Clear any pending reconnect timeout to prevent duplicate connections
2026-03-25 23:32:28 +01:00
this . _clearTimer ( 'sseReconnectTimeout' );
2026-01-27 17:46:43 +01:00
2026-01-27 20:24:00 +01:00
// Clean up existing SSE listeners before creating new connection (prevents listener accumulation)
if ( this . _sseListenerCleanup ) {
this . _sseListenerCleanup ();
this . _sseListenerCleanup = null ;
}
2026-01-26 12:35:00 +01:00
// Close existing EventSource before creating new one to prevent duplicate connections
if ( this . eventSource ) {
this . eventSource . close ();
this . eventSource = null ;
}
2026-01-30 13:25:18 +01:00
// Show connecting state
if ( this . reconnectAttempts === 0 ) {
this . setConnectionStatus ( 'connecting' );
} else {
this . setConnectionStatus ( 'reconnecting' );
}
2026-05-16 23:56:53 -04:00
// Build URL with stable client ID and (if known) the active-session
// filter so the server only streams session:terminal events for the
// session we're rendering. Lifecycle/metadata events are sent globally
// regardless of filter (server side).
const _sseParams = new URLSearchParams ({ clientId : this . _clientId });
if ( this . activeSessionId ) _sseParams . set ( 'sessions' , this . activeSessionId );
this . eventSource = new EventSource ( `/api/events? ${ _sseParams . toString () } ` );
2026-01-18 05:14:34 +01:00
2026-01-27 20:24:00 +01:00
// Store all event listeners for cleanup on reconnect
const listeners = [];
const addListener = ( event , handler ) => {
2026-01-30 14:14:45 +01:00
this . eventSource . addEventListener ( event , handler );
listeners . push ({ event , handler });
2026-01-27 20:24:00 +01:00
};
// Create cleanup function to remove all listeners
this . _sseListenerCleanup = () => {
for ( const { event , handler } of listeners ) {
if ( this . eventSource ) {
this . eventSource . removeEventListener ( event , handler );
}
}
listeners . length = 0 ;
};
2026-01-30 13:25:18 +01:00
this . eventSource . onopen = () => {
this . reconnectAttempts = 0 ;
this . setConnectionStatus ( 'connected' );
};
2026-01-18 05:14:34 +01:00
this . eventSource . onerror = () => {
2026-01-30 13:25:18 +01:00
this . reconnectAttempts ++ ;
if ( this . reconnectAttempts >= this . maxReconnectAttempts ) {
this . setConnectionStatus ( 'disconnected' );
} else {
this . setConnectionStatus ( 'reconnecting' );
}
2026-01-26 12:35:00 +01:00
// Close the failed connection before scheduling reconnect
if ( this . eventSource ) {
this . eventSource . close ();
this . eventSource = null ;
}
2026-01-27 17:46:43 +01:00
// Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts)
2026-03-25 23:32:28 +01:00
this . _clearTimer ( 'sseReconnectTimeout' );
2026-02-19 23:03:21 +01:00
// Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s
// Fast first retry (200ms) for server-restart case (COM deploy),
// then ramp up for real network issues.
const delay = this . reconnectAttempts <= 1 ? 200
: Math . min ( 500 * Math . pow ( 2 , this . reconnectAttempts - 2 ), 30000 );
2026-01-30 13:25:18 +01:00
this . sseReconnectTimeout = setTimeout (() => this . connectSSE (), delay );
2026-01-18 05:14:34 +01:00
};
2026-03-03 22:56:40 +01:00
// Create stable handler wrappers once (reused across reconnects so
// removeEventListener always matches the original reference)
if ( ! this . _sseHandlerWrappers ) {
this . _sseHandlerWrappers = new Map ();
for ( const [ event , method ] of _SSE_HANDLER_MAP ) {
const fn = this [ method ];
this . _sseHandlerWrappers . set ( event , ( e ) => {
try {
fn . call ( this , e . data ? JSON . parse ( e . data ) : {});
} catch ( err ) {
console . error ( `[SSE] Error handling ${ event } :` , err );
}
});
}
}
2026-03-03 22:40:18 +01:00
// Register all SSE event handlers via centralized map
2026-03-03 22:56:40 +01:00
for ( const [ event ] of _SSE_HANDLER_MAP ) {
addListener ( event , this . _sseHandlerWrappers . get ( event ));
2026-03-03 22:40:18 +01:00
}
2026-06-18 12:39:38 -04:00
// COD-121: live-refresh the unified session list (Session Manager modal +
// visible welcome list) on session structural changes. Extra listeners on the
// same EventSource — EventSource supports multiple listeners per event — so the
// existing handlers above are untouched. Registered through addListener so they
2026-07-13 00:38:56 +02:00
// are torn down with the rest on reconnect. Only structural events (created /
// deleted) trigger a refetch: session:updated is batch-broadcast every ~500ms
// per active session, which would otherwise turn an open modal / visible welcome
// list into a sustained ~1 Hz full ~/.claude/projects rescan loop.
for ( const event of [ SSE_EVENTS . SESSION_CREATED , SSE_EVENTS . SESSION_DELETED ]) {
2026-06-18 12:39:38 -04:00
addListener ( event , () => this . _onSessionListMaybeChanged ());
}
2026-06-20 12:18:07 -04:00
2026-07-19 19:28:13 +02:00
// Docker export/import: toast + refresh the Manage-tab exports list on completion.
addListener ( SSE_EVENTS . DOCKER_EXPORT_COMPLETE , ( e ) => {
try {
const d = e . data ? JSON . parse ( e . data ) : {};
this . showToast ( `Docker export ready: ${ d . bundle } ( ${ Math . round (( d . sizeBytes || 0 ) / 1e6 ) } MB)` , 'success' );
this . refreshDockerExports ? .();
} catch ( err ) {
console . error ( '[SSE] docker export complete:' , err );
}
});
addListener ( SSE_EVENTS . DOCKER_EXPORT_FAILED , ( e ) => {
try {
const d = e . data ? JSON . parse ( e . data ) : {};
this . showToast ( `Docker export failed: ${ d . error || 'unknown error' } ` , 'error' );
} catch ( err ) {
console . error ( '[SSE] docker export failed:' , err );
}
});
2026-07-20 02:36:02 +02:00
// Import + drift-recreate completions: refresh case lists in EVERY open tab
// (the initiating tab already refreshes via its own fetch response).
addListener ( SSE_EVENTS . DOCKER_IMPORT_COMPLETE , ( e ) => {
try {
const d = e . data ? JSON . parse ( e . data ) : {};
this . showToast ( `Docker bundle imported as case " ${ d . name } "` , 'success' );
this . loadQuickStartCases ? .();
this . refreshDockerExports ? .();
} catch ( err ) {
console . error ( '[SSE] docker import complete:' , err );
}
});
addListener ( SSE_EVENTS . DOCKER_CONTAINER_RECREATED , ( e ) => {
try {
const d = e . data ? JSON . parse ( e . data ) : {};
this . showToast ( `Container for " ${ d . name } " removed — next launch recreates it with the new config` , 'info' );
} catch ( err ) {
console . error ( '[SSE] docker container recreated:' , err );
}
});
2026-07-20 17:35:40 +02:00
// Multi-user admin: live-refresh whichever admin views (panel/Users tab) are open.
addListener ( SSE_EVENTS . ADMIN_USERS_CHANGED , () => {
window . codemanAdmin ? . onUsersChanged ? .();
});
2026-07-20 01:28:53 +02:00
// Base image auto-build on first Docker case (build-on-first-use). A single
// multi-minute event; surface start/finish so the Run spinner is explained.
addListener ( SSE_EVENTS . DOCKER_IMAGE_BUILD_STARTED , () => {
this . showToast ( 'Building the Codeman agent image (first Docker case, a few minutes)...' , 'info' , {
duration : 8000 ,
});
});
addListener ( SSE_EVENTS . DOCKER_IMAGE_BUILD_COMPLETE , ( e ) => {
try {
const d = e . data ? JSON . parse ( e . data ) : {};
if ( d . error ) this . showToast ( `Agent image build failed: ${ d . error } ` , 'error' );
else this . showToast ( 'Agent image ready. Starting the container...' , 'success' );
} catch ( err ) {
console . error ( '[SSE] docker image build complete:' , err );
}
});
addListener ( SSE_EVENTS . DOCKER_IMAGE_BUILD_FAILED , ( e ) => {
try {
const d = e . data ? JSON . parse ( e . data ) : {};
this . showToast ( `Agent image build failed: ${ d . error || 'unknown error' } ` , 'error' );
} catch ( err ) {
console . error ( '[SSE] docker image build failed:' , err );
}
});
2026-07-20 14:31:44 +02:00
2026-06-20 12:18:07 -04:00
// COD-139: a session:pinned event updates the local live-session pin flag (so
// a subsequent render is consistent) and re-sorts the open session manager /
// welcome list so pinned sessions float to the top.
addListener ( SSE_EVENTS . SESSION_PINNED , ( e ) => {
let data = null ;
try {
data = JSON . parse ( e . data );
} catch {
/* ignore malformed payload */
}
if ( data && data . id ) {
const live = this . sessions . get ( data . id );
if ( live ) {
live . pinned = data . pinned === true ;
live . pinnedAt = data . pinned ? data . pinnedAt : undefined ;
}
}
this . _onSessionListMaybeChanged ();
});
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-06-06 03:53:54 +02:00
// Solo window whose session just ended → show the "unavailable" state.
if ( this . isSoloWindow && data . id === this . soloSessionId ) {
this . _showSoloSessionGone ();
}
// Dashboard: a detached session ended → clear its detached state/timers.
if ( this . detachedSessions . has ( data . id )) this . _redock ( data . id );
2026-03-03 22:40:18 +01:00
this . _cleanupSessionData ( data . id );
if ( this . activeSessionId === data . id ) {
this . activeSessionId = null ;
try { localStorage . removeItem ( 'codeman-active-session' ); } catch {}
this . terminal . clear ();
this . showWelcome ();
}
this . renderSessionTabs ();
this . renderRalphStatePanel (); // Update ralph panel after session deleted
this . renderProjectInsightsPanel (); // Update project insights panel after session deleted
// Stop stats polling when no sessions remain
if ( this . sessions . size === 0 ) this . stopSystemStatsPolling ();
}
2026-03-12 21:02:28 -04:00
// SSE wrappers — skip terminal events when WebSocket is delivering for this session.
// WS handler calls the underlying _onSession* methods directly.
_onSSETerminal ( data ) {
if ( this . _wsReady && this . _wsSessionId === data . id ) return ;
this . _onSessionTerminal ( data );
}
_onSSENeedsRefresh ( data ) {
if ( this . _wsReady && this . _wsSessionId === data ? . id ) return ;
this . _onSessionNeedsRefresh ( data );
}
_onSSEClearTerminal ( data ) {
if ( this . _wsReady && this . _wsSessionId === data ? . id ) return ;
this . _onSessionClearTerminal ( data );
}
2026-03-03 22:40:18 +01:00
_onSessionTerminal ( data ) {
if ( data . id === this . activeSessionId ) {
2026-03-05 16:44:17 +01:00
if ( data . data . length > 32768 ) _crashDiag . log ( `TERMINAL: ${ ( data . data . length / 1024 ). toFixed ( 0 ) } KB` );
2026-03-06 17:10:25 +01:00
2026-03-07 07:00:18 +01:00
// Hard cap: track total bytes queued in render buffers (pendingWrites +
// flickerFilterBuffer). When rAF is throttled (tab
2026-03-06 17:10:25 +01:00
// backgrounded, GPU busy), data accumulates with no flush, reaching
2026-03-07 07:00:18 +01:00
// 889KB+ and freezing Chrome for minutes. Drop data beyond 128KB and
// schedule a buffer reload to recover the display once the burst subsides.
2026-03-06 17:10:25 +01:00
const queued = ( this . pendingWrites ? . reduce (( s , w ) => s + w . length , 0 ) || 0 )
+ ( this . flickerFilterBuffer ? . length || 0 );
2026-03-07 07:00:18 +01:00
if ( queued > 131072 ) { // 128KB — drop to prevent accumulation
// Schedule a self-recovery: reload the full terminal buffer once the
// queue drains (debounced to avoid hammering the API during sustained bursts).
if ( ! this . _clientDropRecoveryTimer ) {
this . _clientDropRecoveryTimer = setTimeout (() => {
this . _clientDropRecoveryTimer = null ;
this . _onSessionNeedsRefresh ();
}, 2000 );
}
return ;
}
2026-03-06 17:10:25 +01:00
2026-03-03 22:40:18 +01:00
this . batchTerminalWrite ( data . data );
}
}
2026-04-10 19:07:02 +08:00
// ═══════════════════════════════════════════════════════════════
// Response Viewer — native-scroll panel for reading full Claude responses
// ═══════════════════════════════════════════════════════════════
2026-04-11 07:20:09 +02:00
/** Strip dangerous elements and attributes from HTML (XSS prevention) */
_sanitizeHtml ( html ) {
2026-06-14 12:33:48 -04:00
if ( typeof window !== 'undefined' && typeof window . sanitizeMarkdownHtml === 'function' ) {
return window . sanitizeMarkdownHtml ( html );
2026-04-11 07:20:09 +02:00
}
2026-06-14 12:33:48 -04:00
// Fail closed: DOMPurify unavailable — never return un-sanitized HTML.
return String ( html == null ? '' : html )
. replace ( /&/g , '&' )
. replace ( /</g , '<' )
. replace ( />/g , '>' );
2026-04-11 07:20:09 +02:00
}
2026-04-28 08:58:42 +08:00
/**
* Strip ANSI escape sequences and Claude CLI chrome (status bar, hints,
* spinner, progress bar) from a terminal buffer so the response viewer can
* show just the conversational text when the JSONL transcript is missing.
*/
_cleanTerminalBuffer ( buf ) {
const stripped = buf
// CSI sequences — params (0x30-0x3F includes digits, ?, ;, <, =, >),
// intermediates (0x20-0x2F), final byte (0x40-0x7E). Catches \x1b[>c,
// \x1b[>q, \x1b[?25l etc. that the previous regex missed.
. replace ( /\x1b\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g , '' )
// OSC sequences (window titles etc.) terminated by BEL or ST
. replace ( /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g , '' )
// DCS / APC / PM / SOS sequences
. replace ( /\x1b[PX^_][^\x1b]*\x1b\\/g , '' )
// SS2/SS3 + charset selects + single-char escapes
. replace ( /\x1b[NO()][A-Z0-9]?/g , '' )
. replace ( /\x1b[>=<78cDEHM]/g , '' )
// Stray control chars (except \t \n)
. replace ( /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g , '' )
. replace ( /\r\n/g , '\n' ). replace ( /\r/g , '\n' );
// Drop Claude CLI chrome lines that aren't part of the response.
const CHROME_PATTERNS = [
/^\s*❯ \s*/ , // shell prompt
/^\s*[⏵⏺⏸⏹]+\s*/ , // status glyphs
/^\s*✻\s*(Crunching|Crunched|Thinking)/i , // spinner lines
/bypass permissions/i ,
/\bshift\+tab to cycle\b/i ,
/^\s*focus\s*$/ ,
/^\s*new task\?/i ,
/\/clear to save/i ,
/^\s*─{5,}\s*$/ , // horizontal dividers
/\[(Opus|Sonnet|Haiku|GPT|Claude)[\s\S]*(tokens?|\$|¥|%|↑|↓)/i , // status bar
/^\s*\[\d+[km]?\/\d+[km]?\]/i , // token counter
/[█░▓▒]{3,}/ , // progress bar
/^\s*\(.*\s*(tokens?|context).*\)\s*$/i ,
];
const lines = stripped . split ( '\n' );
const kept = lines . filter (( line ) => {
const trimmed = line . trim ();
if ( ! trimmed ) return true ; // keep blanks so paragraphs survive
return ! CHROME_PATTERNS . some (( re ) => re . test ( line ));
});
return kept
. join ( '\n' )
. replace ( /[ \t]+$/gm , '' )
. replace ( /\n{4,}/g , '\n\n\n' )
. trim ();
}
/**
* Wrap ASCII/box diagrams in fenced code blocks so marked.js preserves whitespace.
* Claude often emits box-drawing diagrams without triple-backticks; without this
* step, HTML collapses the whitespace and the diagram becomes unreadable prose.
*/
_preprocessAsciiArt ( text ) {
// Only trigger on characters that rarely appear in prose:
// U+2500-U+257F Box Drawing (─│┌┐└┘├┤┬┴┼╔╗╚╝═║)
// U+2580-U+259F Block Elements (▀▄█▌▐░▒▓, progress bars)
// Deliberately excluded:
// U+2190-U+21FF Arrows (→←↑↓⇒ — common rhetorical prose)
// U+25A0-U+25FF Geometric Shapes (●○■□◆◇ — common bullets)
// Triggering on those would wrap numbered lists / prose that merely uses
// arrows in code blocks and break their markdown rendering.
const BOX_PATTERN = /[─-╿▀-▟]/ ;
// Preserve existing fenced code blocks as-is (hide them behind placeholders)
const fenceRe = /```[\s\S]*?```/g ;
const placeholders = [];
const masked = text . replace ( fenceRe , ( m ) => {
placeholders . push ( m );
2026-06-08 09:56:06 -04:00
return `__CODEMAN_FENCE_ ${ placeholders . length - 1 } __` ;
2026-04-28 08:58:42 +08:00
});
// Split on blank-line paragraph boundaries; wrap any paragraph containing
// box-drawing/arrow chars in its own fenced block.
const processed = masked
. split ( /(\n{2,})/ )
. map (( chunk ) => {
if ( /^\n{2,}$/ . test ( chunk )) return chunk ; // keep separators
if ( ! chunk . trim ()) return chunk ;
2026-06-08 09:56:06 -04:00
if ( chunk . includes ( '__CODEMAN_FENCE_' )) return chunk ;
2026-04-28 08:58:42 +08:00
if ( BOX_PATTERN . test ( chunk )) return '\n```\n' + chunk + '\n```\n' ;
return chunk ;
})
. join ( '' );
2026-06-08 09:56:06 -04:00
return processed . replace ( /__CODEMAN_FENCE_(\d+)__/g , ( _m , i ) => placeholders [ Number ( i )]);
2026-04-28 08:58:42 +08:00
}
2026-04-10 19:12:06 +08:00
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
_renderMarkdown ( text ) {
2026-06-02 01:43:52 +08:00
const src = text || '' ;
2026-04-10 19:12:06 +08:00
if ( typeof marked !== 'undefined' && marked . parse ) {
try {
2026-06-02 01:43:52 +08:00
const prepared = this . _preprocessAsciiArt ( src );
2026-04-28 08:58:42 +08:00
let html = this . _sanitizeHtml ( marked . parse ( prepared , { breaks : true , gfm : true }));
// Wrap tables in a horizontal-scroll container so they overflow gracefully
// on mobile without collapsing into block-level cells.
html = html . replace ( /<table>/g , '<div class="rv-table-wrap"><table>' )
. replace ( /<\/table>/g , '</table></div>' );
// Tag code blocks containing box-drawing glyphs as diagrams (same
// narrow trigger as _preprocessAsciiArt — arrows/geometric shapes
// don't count because they appear frequently in prose).
// Default is wrap (readable on mobile); a toggle button lets the user
// switch to horizontal-scroll mode when the original structure matters.
// The button must live OUTSIDE the <pre> scroll container so it stays
// pinned to the visual right edge when the user scrolls horizontally.
const DIAGRAM_CHAR = /[─-╿▀-▟]/ ;
const tmpl = document . createElement ( 'template' );
tmpl . innerHTML = html ;
2026-05-26 06:00:37 +08:00
// Every fenced code block gets a positioned wrapper with an action
// toolbar pinned to its top-right corner. The toolbar lives OUTSIDE the
// <pre> scroll container so its buttons stay put during horizontal
// scroll. All blocks get a one-click copy button; ASCII diagrams keep
// the additional line-wrap toggle.
2026-04-28 08:58:42 +08:00
tmpl . content . querySelectorAll ( 'pre > code' ). forEach (( code ) => {
const pre = code . parentElement ;
2026-05-26 06:00:37 +08:00
const isDiagram = DIAGRAM_CHAR . test ( code . textContent || '' );
2026-04-28 08:58:42 +08:00
const wrap = document . createElement ( 'div' );
2026-05-26 06:00:37 +08:00
wrap . className = isDiagram ? 'rv-code-wrap rv-diagram-wrap' : 'rv-code-wrap' ;
2026-04-28 08:58:42 +08:00
2026-05-26 06:00:37 +08:00
const actions = document . createElement ( 'div' );
actions . className = 'rv-code-actions' ;
const copyBtn = document . createElement ( 'button' );
copyBtn . className = 'rv-copy-btn' ;
copyBtn . type = 'button' ;
copyBtn . setAttribute ( 'aria-label' , 'Copy code' );
copyBtn . setAttribute ( 'title' , 'Copy code' );
actions . appendChild ( copyBtn );
if ( isDiagram ) {
pre . classList . add ( 'rv-diagram' );
const toggle = document . createElement ( 'button' );
toggle . className = 'rv-wrap-toggle' ;
toggle . type = 'button' ;
toggle . setAttribute ( 'aria-label' , 'Toggle line wrapping' );
toggle . setAttribute ( 'title' , 'Toggle line wrapping' );
actions . appendChild ( toggle );
}
2026-04-28 08:58:42 +08:00
pre . parentNode . insertBefore ( wrap , pre );
2026-05-26 06:00:37 +08:00
wrap . appendChild ( actions );
2026-04-28 08:58:42 +08:00
wrap . appendChild ( pre );
});
return tmpl . innerHTML ;
2026-04-10 19:12:06 +08:00
} catch { /* fall through */ }
}
// Fallback: escape HTML and preserve whitespace
2026-06-02 01:43:52 +08:00
const escaped = src . replace ( /&/g , '&' ). replace ( /</g , '<' ). replace ( />/g , '>' );
2026-04-10 19:12:06 +08:00
return `<pre style="white-space:pre-wrap;word-break:break-word"> ${ escaped } </pre>` ;
}
2026-04-28 08:58:42 +08:00
/**
* Bind click handlers inside the response viewer body. Uses event delegation
* so a single listener serves every diagram-toggle button, including those
* added when the conversation is reloaded. Idempotent via a dataset flag.
*/
_bindResponseViewerInteractions ( body ) {
if ( ! body || body . dataset . rvBound === '1' ) return ;
body . dataset . rvBound = '1' ;
2026-05-26 06:00:37 +08:00
body . addEventListener ( 'click' , async ( ev ) => {
// One-click copy: lift the raw source from the sibling <pre><code>.
const copyBtn = ev . target . closest ( '.rv-copy-btn' );
if ( copyBtn ) {
ev . preventDefault ();
ev . stopPropagation ();
const code = copyBtn . closest ( '.rv-code-wrap' ) ? . querySelector ( 'pre code' );
const ok = code ? await this . _copyText ( code . textContent || '' ) : false ;
copyBtn . classList . remove ( 'rv-copied' , 'rv-copy-failed' );
copyBtn . classList . add ( ok ? 'rv-copied' : 'rv-copy-failed' );
clearTimeout ( copyBtn . _resetTimer );
copyBtn . _resetTimer = setTimeout (() => {
copyBtn . classList . remove ( 'rv-copied' , 'rv-copy-failed' );
}, 1500 );
return ;
}
2026-04-28 08:58:42 +08:00
const btn = ev . target . closest ( '.rv-wrap-toggle' );
if ( ! btn ) return ;
ev . preventDefault ();
ev . stopPropagation ();
const wrap = btn . closest ( '.rv-diagram-wrap' );
const pre = wrap ? . querySelector ( 'pre.rv-diagram' );
if ( ! pre || ! wrap ) return ;
const nowrap = pre . classList . toggle ( 'rv-nowrap' );
wrap . classList . toggle ( 'rv-wrap-nowrap' , nowrap );
});
}
2026-05-26 06:00:37 +08:00
/**
* Copy text to the clipboard. Prefers the async Clipboard API (secure
* contexts); falls back to a hidden-textarea + execCommand path so copy
* still works over plain HTTP. Returns true on success.
*/
async _copyText ( text ) {
if ( ! text ) return false ;
try {
if ( navigator . clipboard ? . writeText ) {
await navigator . clipboard . writeText ( text );
return true ;
}
} catch { /* secure-context write failed — try the legacy path */ }
try {
const ta = document . createElement ( 'textarea' );
ta . value = text ;
ta . setAttribute ( 'readonly' , '' );
ta . style . cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none' ;
document . body . appendChild ( ta );
ta . select ();
const ok = document . execCommand ( 'copy' );
document . body . removeChild ( ta );
return ok ;
} catch {
return false ;
}
}
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` );
2026-06-10 01:30:43 +02:00
const data = ( await res . json ()) ? . data ?? {};
2026-04-10 19:07:02 +08:00
let lastResponse = data . text || '' ;
2026-07-12 10:39:42 +08:00
// Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome.
2026-07-12 17:42:39 +02:00
// Claude + shell only: _cleanTerminalBuffer knows Claude CLI's output, and
// shell sessions have no transcript source at all; for TUI modes
// (codex/opencode/gemini) it yields repaint garbage, so a clear
2026-07-12 10:39:42 +08:00
// placeholder beats a messy screen dump there.
const sessionMode = this . sessions . get ( this . activeSessionId ) ? . mode || 'claude' ;
2026-07-12 17:42:39 +02:00
if ( ! lastResponse && ( sessionMode === 'claude' || sessionMode === 'shell' )) {
2026-04-10 19:07:02 +08:00
const termRes = await fetch ( `/api/sessions/ ${ this . activeSessionId } /terminal` );
2026-06-10 01:30:43 +02:00
const termData = ( await termRes . json ()) ? . data ?? {};
2026-04-10 19:07:02 +08:00
if ( termData . terminalBuffer ) {
2026-04-28 08:58:42 +08:00
lastResponse = this . _cleanTerminalBuffer ( termData . terminalBuffer );
2026-04-10 19:07:02 +08:00
}
}
const body = document . getElementById ( 'responseViewerBody' );
2026-07-12 10:39:42 +08:00
if ( lastResponse ) {
body . innerHTML = this . _renderMarkdown ( lastResponse );
this . _bindResponseViewerInteractions ( body );
} else {
body . textContent = 'No response yet — send a message in this session first.' ;
}
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` );
2026-06-10 01:30:43 +02:00
const data = ( await res . json ()) ? . data ?? {};
2026-04-10 19:07:02 +08:00
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
2026-07-12 10:39:42 +08:00
const mode = this . sessions . get ( this . activeSessionId ) ? . mode ;
const agentLabel =
mode === 'codex' ? 'Codex' : mode === 'gemini' ? 'Gemini' : mode === 'opencode' ? 'OpenCode' : 'Claude' ;
2026-04-10 19:07:02 +08:00
body . innerHTML = '' ;
for ( const msg of messages ) {
const div = document . createElement ( 'div' );
2026-04-28 08:58:42 +08:00
const isUser = msg . role === 'user' ;
div . className = 'rv-message ' + ( isUser ? 'rv-msg-user' : 'rv-msg-assistant' );
2026-04-10 19:07:02 +08:00
const role = document . createElement ( 'div' );
2026-04-28 08:58:42 +08:00
role . className = 'rv-role ' + ( isUser ? 'rv-role-user' : 'rv-role-assistant' );
2026-07-12 10:39:42 +08:00
role . textContent = isUser ? 'You' : agentLabel ;
2026-04-10 19:07:02 +08:00
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 );
}
2026-04-28 08:58:42 +08:00
this . _bindResponseViewerInteractions ( body );
2026-04-10 19:07:02 +08:00
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-06-10 01:30:43 +02:00
const data = ( await res . json ()) ? . data ?? {};
2026-03-03 22:40:18 +01:00
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` );
2026-06-10 01:30:43 +02:00
const termData = ( await res . json ()) ? . data ?? {};
2026-03-03 22:40:18 +01:00
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-06-10 20:41:34 +02:00
_onSessionLimitPauseScheduled ( data ) {
const session = this . sessions . get ( data . sessionId );
if ( session ) session . autoResumeAt = data . resumeAt ;
const at = new Date ( data . resumeAt ). toLocaleTimeString ([], { hour : '2-digit' , minute : '2-digit' });
if ( data . sessionId === this . activeSessionId ) {
this . showToast ( `Usage limit reached — auto-resume at ${ at } ` , 'warning' );
}
this . _notifySession ( data . sessionId , 'warning' , 'limit-pause' , 'Usage Limit Reached' , `Auto-resume scheduled for ${ at } ` );
this . updateAutoResumeStatus ( data . sessionId );
}
_onSessionLimitResume ( data ) {
const session = this . sessions . get ( data . sessionId );
if ( session ) session . autoResumeAt = undefined ;
if ( data . sessionId === this . activeSessionId ) {
this . showToast ( 'Usage limit reset — work resumed automatically' , 'success' );
}
this . _notifySession ( data . sessionId , 'info' , 'limit-resume' , 'Auto-Resumed' , 'Usage limit reset — continuing work' );
this . updateAutoResumeStatus ( data . sessionId );
}
_onSessionLimitResumeCancelled ( data ) {
const session = this . sessions . get ( data . sessionId );
if ( session ) session . autoResumeAt = undefined ;
this . updateAutoResumeStatus ( data . sessionId );
}
2026-06-16 08:53:29 -04:00
// COD-118: the interactive PTY exit circuit breaker tripped (repeated non-zero exits).
// The errored status itself arrives via session:updated; this just surfaces a toast for
// diagnostic clarity so a silently-looping session is obvious. Restart clears the breaker.
_onSessionRespawnBreakerTripped ( data ) {
const session = this . sessions . get ( data . sessionId );
const label = session ? . name || 'Session' ;
this . showToast ? .( ` ${ label } stopped: repeated crashes detected. Restart to retry.` , 'error' );
}
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 ();
}
}
2026-06-14 05:00:28 +02:00
// Claude plan usage limits (5-hour + weekly) — account-global, so the latest
// sample from any session drives the shared header chip.
_onSessionStatusTelemetry ( data ) {
this . updatePlanUsageChip ( data );
2026-06-14 05:44:54 +02:00
// Persist last-known so the chip shows immediately on the next page load /
// SSE reconnect, instead of staying blank until a session next renders.
try {
localStorage . setItem ( 'codeman:planUsage' , JSON . stringify ({ t : Date . now (), data }));
} catch {}
}
// Repopulate the chip from the last-known value on page load (account-global,
// slow-moving; ignored if older than 12h). Live events refresh it.
restorePlanUsageChip () {
try {
const raw = localStorage . getItem ( 'codeman:planUsage' );
if ( ! raw ) return ;
const saved = JSON . parse ( raw );
if ( saved ? . data && Date . now () - ( saved . t || 0 ) < 12 * 3600 * 1000 ) {
this . updatePlanUsageChip ( saved . data );
}
} catch {}
2026-06-14 05:00:28 +02:00
}
updatePlanUsageChip ( data ) {
const chip = document . getElementById ( 'planUsageChip' );
if ( ! chip || ! data ) return ;
const pct = ( w ) => ( w && typeof w . usedPercentage === 'number' ? Math . round ( w . usedPercentage ) : null );
const five = pct ( data . fiveHour );
const seven = pct ( data . sevenDay );
if ( five === null && seven === null ) return ;
2026-06-14 05:44:54 +02:00
// Per-window color by how much is used up: green < 60%, yellow 60– 84%, red ≥ 85%.
const colorClass = ( p ) => ( p >= 85 ? 'pu-red' : p >= 60 ? 'pu-yellow' : 'pu-green' );
2026-06-14 07:33:52 +02:00
// innerHTML here is XSS-safe ONLY because every interpolated value is a
// coerced finite number and the labels/classes are fixed literals. If a
// string field (e.g. modelDisplayName, which the route also broadcasts) is
// ever shown in this chip, render it via textContent — never interpolate an
// untrusted string into this template.
const seg = ( label , p ) => {
if ( p === null ) return '' ;
const n = Math . round ( Number ( p ));
if ( ! Number . isFinite ( n )) return '' ;
return `<span class="pu-win"><span class="pu-label"> ${ label } </span><span class="pu-val ${ colorClass ( n ) } "> ${ n } %</span></span>` ;
};
2026-06-14 05:44:54 +02:00
chip . innerHTML = [ seg ( '5h' , five ), seg ( '7d' , seven )]. filter ( Boolean ). join ( '<span class="pu-sep">·</span>' );
2026-06-14 05:00:28 +02:00
const resetStr = ( w ) => ( w && w . resetAt ? new Date ( w . resetAt ). toLocaleString () : '—' );
chip . title =
`Claude plan usage\n` +
2026-06-14 05:44:54 +02:00
`5-hour limit: ${ five ?? '—' } % used (resets ${ resetStr ( data . fiveHour ) } )\n` +
`Weekly limit: ${ seven ?? '—' } % used (resets ${ resetStr ( data . sevenDay ) } )` ;
2026-06-14 05:00:28 +02:00
}
2026-03-03 22:40:18 +01:00
// 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 ();
2026-06-19 16:44:18 +02:00
if ( status === 'connected' ) {
// Reconnected (SSE) — push any durably-queued input out immediately
// instead of waiting for the next 2s sweep.
this . _redeliverSweep ();
2026-02-12 08:34:51 +01:00
}
}
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 ();
2026-07-12 18:42:36 +02:00
this . _wsState = 'connecting' ;
this . _updateConnectionIndicator ();
2026-03-12 21:02:28 -04:00
const proto = location . protocol === 'https:' ? 'wss:' : 'ws:' ;
2026-07-12 18:42:36 +02:00
// Pass a per-TAB identity on the upgrade URL so the server's connection
// registry scopes the per-session limit by connection (COD-137): a same-tab
2026-06-20 09:53:52 -04:00
// reconnect supersedes its own socket instead of consuming a new slot and
2026-07-12 18:42:36 +02:00
// tripping a spurious 4008, while two tabs of the same browser (which share
// the localStorage clientId) each keep their own socket. The bare clientId
// still rides the input frames for seq dedup. Omitted if clientId is
// unavailable (server then treats the upgrade as anonymous — still admitted
// up to the limit).
const cid = this . _clientId ? ` ${ this . _clientId } : ${ this . _wsTabNonce } ` : '' ;
const cidQuery = cid ? `?cid= ${ encodeURIComponent ( cid ) } ` : '' ;
2026-06-20 09:53:52 -04:00
const url = ` ${ proto } // ${ location . host } /ws/sessions/ ${ sessionId } /terminal ${ cidQuery } ` ;
2026-03-12 21:02:28 -04:00
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-07-12 18:42:36 +02:00
this . _wsState = 'connected' ;
2026-03-14 18:37:10 +01:00
this . _wsReconnectAttempts = 0 ;
2026-06-19 21:32:42 -04:00
this . _updateConnectionIndicator ();
2026-06-10 04:31:45 +02:00
// Send a typed resize over the fresh socket: syncs PTY dims after
// (re)connects AND registers the desktop sizing claim server-side —
// selectSession's earlier resizes ran before this WS existed, so they
// went over HTTP, which never claims (see ws-routes sizingToken).
this . sendResize ( sessionId ) ? . catch ? .(() => {});
2026-06-10 20:41:34 +02:00
this . _startMobileResizeRetry ( sessionId );
2026-06-19 16:44:18 +02:00
// Flush any durably-queued input over the fresh socket (covers frames a
// prior half-open socket silently dropped, and input typed while offline).
this . _onWsReady ( sessionId );
2026-03-12 21:02:28 -04:00
}
};
ws . onmessage = ( event ) => {
if ( this . _ws !== ws ) return ;
2026-06-19 21:32:42 -04:00
// Mark the socket as alive on every received frame (output, ACK, etc.) so
// the redeliver sweep only force-closes a genuinely silent connection.
this . _wsLastRecvAt = Date . now ();
2026-03-12 21:02:28 -04:00
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 });
2026-06-19 16:44:18 +02:00
} else if ( msg . t === 'ia' ) {
// Input ACK — the server applied (or deduped) this seq; drop it from
// the durable queue so it can never be re-delivered/lost.
this . _onWsInputAck ( msg . seq );
2026-03-12 21:02:28 -04:00
}
} 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 ;
2026-06-10 20:41:34 +02:00
this . _stopMobileResizeRetry ();
2026-03-14 18:37:10 +01:00
2026-06-19 21:32:42 -04:00
// Decide what to do next from the close code + how many consecutive
// reconnects we've already made (pure policy in constants.js):
// reconnect → transient (server restart, network blip, ping timeout);
// schedule a backoff retry while this session stays active.
// retry-fallback → too-many-connections / unknown rejection; show the HTTP
// fallback but keep retrying so we return to WS when it clears.
// give-up → 4004 (not found) / 4009 (terminated); the session is gone.
// _disconnectWs() nulls onclose for intentional disconnects, so we never land here for those.
const plan = window . CodemanWsReconnect . plan ( event . code , this . _wsReconnectAttempts || 0 );
_crashDiag . log (
`WS CLOSE code= ${ event . code } reason= ${ event . reason || '' } action= ${ plan . action } attempts= ${ this . _wsReconnectAttempts || 0 } `
);
const stillActive = this . activeSessionId === sessionId ;
if ( plan . action === 'give-up' ) {
this . _wsState = stillActive ? 'fallback' : 'disconnected' ;
this . _updateConnectionIndicator ();
} else if ( plan . action === 'reconnect' ) {
if ( stillActive ) {
this . _wsState = 'reconnecting' ;
this . _updateConnectionIndicator ();
const delay = plan . delayMs + Math . floor ( Math . random () * 250 ); // jitter to de-sync herds
this . _wsReconnectAttempts = ( this . _wsReconnectAttempts || 0 ) + 1 ;
this . _wsReconnectTimer = setTimeout (() => {
this . _wsReconnectTimer = null ;
if ( this . activeSessionId === sessionId ) {
this . _connectWs ( sessionId );
}
}, delay );
} else {
this . _wsState = 'disconnected' ;
this . _updateConnectionIndicator ();
}
} else {
// retry-fallback: surface the HTTP fallback, but keep trying on a bounded
// timer so the transport returns to WS once the transient condition clears.
this . _wsState = stillActive ? 'fallback' : 'disconnected' ;
this . _updateConnectionIndicator ();
if ( stillActive ) {
this . _wsReconnectAttempts = ( this . _wsReconnectAttempts || 0 ) + 1 ;
this . _wsReconnectTimer = setTimeout (() => {
this . _wsReconnectTimer = null ;
if ( this . activeSessionId === sessionId ) {
this . _connectWs ( sessionId );
}
}, plan . delayMs );
}
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-07-12 18:42:36 +02:00
// Deliberately do NOT reset _wsReconnectAttempts here: _connectWs() calls
// this first, so a reset would restart the exponential backoff ladder at
// attempt 0 on every retry (≈0ms tight reconnect loop during an outage).
// ws.onopen zeroes the counter once a connection actually succeeds.
this . _wsState = 'disconnected' ;
2026-06-10 20:41:34 +02:00
this . _stopMobileResizeRetry ();
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-06-10 20:41:34 +02:00
/**
* Small-viewport claim-idle retry. While a desktop sizing claim is "hot",
* the server ignores this device's resize (Session.DESKTOP_CLAIM_IDLE_MS),
* and the single resize sent on attach is deduped client-side — without a
* retry, a phone that attached under an active desktop would render a
* desktop-width stream forever. Re-send the current dims periodically (a
* server-side no-op once the pane already matches) so the pane reflows to
* this device shortly after the desktop goes idle. Visible-tab only: a
* phone in a pocket must not steal the pane from an active desktop.
*/
_startMobileResizeRetry ( sessionId ) {
this . _stopMobileResizeRetry ();
const type =
typeof MobileDetection !== 'undefined' && MobileDetection . getDeviceType
? MobileDetection . getDeviceType ()
: 'desktop' ;
if ( type === 'desktop' ) return ;
this . _mobileResizeRetryTimer = setInterval (() => {
if ( document . visibilityState !== 'visible' ) return ;
if ( ! this . _wsReady || this . _wsSessionId !== sessionId ) return ;
// Same guard as throttledResize: while the virtual keyboard is up, a
// fit()+SIGWINCH at the shrunken row count makes Ink re-render garbage
// and shifts the accessory toolbar mid-typing. Retry after it closes.
if ( typeof KeyboardHandler !== 'undefined' && KeyboardHandler . keyboardVisible ) return ;
this . sendResize ( sessionId ) ? . catch ? .(() => {});
}, MOBILE_RESIZE_RETRY_MS );
}
_stopMobileResizeRetry () {
if ( this . _mobileResizeRetryTimer ) {
clearInterval ( this . _mobileResizeRetryTimer );
this . _mobileResizeRetryTimer = null ;
}
}
2026-02-13 06:02:12 +01:00
/**
2026-06-19 16:44:18 +02:00
* Public input entry point — name/signature kept for all call sites.
* Records the input durably, then delivers it reliably (exactly-once). Never
* blocks the keystroke flush; never silently drops on a half-open socket.
* @param {string} sessionId
* @param {string} input
* @param {{useMux?: boolean}} [opts] - useMux only affects the POST fallback.
2026-02-13 06:02:12 +01:00
*/
2026-06-19 16:44:18 +02:00
_sendInputAsync ( sessionId , input , opts ) {
if ( ! sessionId || ! input ) return ;
this . _reliableSend ( sessionId , input , opts ? . useMux === true );
}
2026-07-16 09:33:39 +02:00
/**
* Fire-and-forget input for EPHEMERAL, loss-tolerant streams (e.g. wheel-scroll
* reports). Unlike _sendInputAsync, this never enters the durable seq/ACK queue,
* so it isn't persisted, retried, or counted in the pending-bytes connection
* indicator (which was flickering "11b/22b queued" on every scroll tick). A
* dropped scroll tick is harmless; keystrokes still go through _sendInputAsync.
* The server applies a seq-less {t:'i'} frame / seq-less POST unconditionally
* and sends no ACK (ws-routes.ts, session-routes input handler).
*/
_sendInputEphemeral ( sessionId , input ) {
if ( ! sessionId || ! input ) return ;
if ( this . _ws && this . _ws . readyState === WebSocket . OPEN && this . _wsSessionId === sessionId ) {
try {
this . _ws . send ( JSON . stringify ({ t : 'i' , d : input }));
return ;
} catch {
// socket died mid-send — fall through to a best-effort POST
}
}
// No usable WS for this session: best-effort POST, not queued. Dropped on
// failure — a scroll tick lost while offline needs no recovery.
try {
fetch ( `/api/sessions/ ${ sessionId } /input` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ input }),
keepalive : true ,
}). catch (() => {});
} catch {
// ignore — loss-tolerant
}
}
2026-06-19 16:44:18 +02:00
/** Record one input frame and kick delivery. The record lives until ACKed. */
_reliableSend ( sessionId , data , useMux ) {
const seq = this . _nextSeq ( sessionId );
const rec = { seq , data , useMux : !! useMux , ts : Date . now (), tries : 0 , sentAt : 0 };
let list = this . _pendingDeliveries . get ( sessionId );
if ( ! list ) {
list = [];
this . _pendingDeliveries . set ( sessionId , list );
}
list . push ( rec );
this . _persistReliableState ();
this . _updateConnectionIndicator ();
this . _drainSession ( sessionId );
}
_nextSeq ( sessionId ) {
const next = ( this . _seqCounters . get ( sessionId ) || 0 ) + 1 ;
this . _seqCounters . set ( sessionId , next );
return next ;
}
/** Deliver all unacked records for a session, in seq order. */
_drainSession ( sessionId ) {
const list = this . _pendingDeliveries . get ( sessionId );
if ( ! list || list . length === 0 ) return ;
// Fast path: WebSocket open for this session — fire each not-yet-sent record
// over the single ordered stream. They stay pending until the server ACKs
// them ({t:'ia'}); a frame swallowed by a half-open socket is re-sent after
// the sweep force-reconnects (which resets sentAt=0 in _onWsReady).
if ( this . _ws && this . _ws . readyState === WebSocket . OPEN && this . _wsSessionId === sessionId ) {
for ( const rec of list ) {
if ( rec . sentAt !== 0 ) continue ;
try {
this . _ws . send ( JSON . stringify ({ t : 'i' , d : rec . data , seq : rec . seq , cid : this . _clientId }));
rec . sentAt = Date . now ();
rec . tries ++ ;
} catch {
break ; // socket died mid-send — reconnect/POST drainer retries
}
}
2026-02-13 06:02:12 +01:00
return ;
}
2026-06-19 16:44:18 +02:00
// Slow path: no WS — POST records in order, awaiting each (the HTTP 2xx is
// the ACK). Serialized per session so seq order survives async fetches.
if ( this . _postDraining . has ( sessionId )) return ;
this . _postDraining . add ( sessionId );
( async () => {
2026-03-12 21:02:28 -04:00
try {
2026-06-19 16:44:18 +02:00
for (;;) {
const cur = this . _pendingDeliveries . get ( sessionId );
if ( ! cur || cur . length === 0 ) break ;
// If the WebSocket came back mid-drain, yield to it (the acked stream)
// so we don't redundantly re-POST what onopen is already re-sending.
if ( this . _ws && this . _ws . readyState === WebSocket . OPEN && this . _wsSessionId === sessionId ) {
break ;
}
const rec = cur [ 0 ];
rec . tries ++ ;
rec . sentAt = Date . now ();
let resp = null ;
try {
const body = { input : rec . data , seq : rec . seq , clientId : this . _clientId };
if ( rec . useMux ) body . useMux = true ;
resp = await fetch ( `/api/sessions/ ${ sessionId } /input` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ( body ),
keepalive : rec . data . length < 65536 ,
});
} catch {
resp = null ;
}
if ( resp && resp . ok ) {
this . _ackDelivery ( sessionId , rec . seq );
} else if ( resp && ( resp . status === 404 || resp . status === 410 )) {
// Session no longer exists — the input can never land. Drop it
// rather than retry forever (not a "lost" prompt: the target is gone).
this . _ackDelivery ( sessionId , rec . seq );
} else {
break ; // offline / 5xx — leave queued; sweep + reconnect retry later
}
2026-02-13 06:02:12 +01:00
}
2026-06-19 16:44:18 +02:00
} finally {
this . _postDraining . delete ( sessionId );
}
})();
2026-02-20 13:06:45 +01:00
}
2026-06-19 16:44:18 +02:00
/** Drop an ACKed record (by exact seq) and persist. */
_ackDelivery ( sessionId , seq ) {
const list = this . _pendingDeliveries . get ( sessionId );
if ( list ) {
const idx = list . findIndex (( r ) => r . seq === seq );
if ( idx !== - 1 ) {
list . splice ( idx , 1 );
if ( list . length === 0 ) this . _pendingDeliveries . delete ( sessionId );
// When nothing is left pending anywhere, flush durable state immediately
// (not debounced) so a reload in the next 250ms can't redeliver an
// already-delivered frame — otherwise localStorage briefly still shows it.
if ( this . _pendingDeliveries . size === 0 ) this . _persistReliableNow ();
else this . _persistReliableState ();
this . _updateConnectionIndicator ();
2026-02-12 08:34:51 +01:00
}
}
2026-06-19 16:44:18 +02:00
this . clearPendingHooks ? .( sessionId );
}
/** Server input-ACK frame ({t:'ia',seq}) over the WebSocket. */
_onWsInputAck ( seq ) {
if ( this . _wsSessionId && Number . isInteger ( seq )) this . _ackDelivery ( this . _wsSessionId , seq );
}
/** Called from ws.onopen — flush everything pending over the fresh socket. */
_onWsReady ( sessionId ) {
const list = this . _pendingDeliveries . get ( sessionId );
if ( list ) for ( const r of list ) r . sentAt = 0 ; // fresh socket ⇒ re-send all
this . _drainSession ( sessionId );
}
/**
* Periodic retry. For the active WS session, an oldest frame unacked past the
* timeout means the socket is (half-)dead — close it to force a fast reconnect
* (onclose → reconnect → onopen → _onWsReady re-sends). Other sessions just
* (re)drain over POST.
*/
_redeliverSweep () {
if ( this . _pendingDeliveries . size === 0 ) return ;
for ( const sessionId of [... this . _pendingDeliveries . keys ()]) {
const list = this . _pendingDeliveries . get ( sessionId );
if ( ! list || list . length === 0 ) continue ;
const isActiveWs =
this . _ws && this . _ws . readyState === WebSocket . OPEN && this . _wsSessionId === sessionId ;
if ( isActiveWs ) {
const oldest = list [ 0 ];
2026-06-19 21:32:42 -04:00
// Only tear the socket down when the oldest unacked frame is stale AND the
// socket has been silent for the timeout: a connection still delivering
// output/ACKs is alive (the ACK is just behind), so force-closing it would
// cause needless WS↔HTTP flapping. A truly half-open socket goes quiet.
const stale = oldest && oldest . sentAt && Date . now () - oldest . sentAt > this . _reliableAckTimeoutMs ;
const silent = Date . now () - this . _wsLastRecvAt > this . _reliableAckTimeoutMs ;
if ( stale && silent ) {
2026-06-19 16:44:18 +02:00
try {
this . _ws . close (); // half-open: never recovers on its own — force reconnect
} catch {
/* ignore */
}
continue ;
}
2026-06-20 09:31:14 -04:00
if ( stale ) {
// Stale but the socket is still delivering output: the ACK was lost,
// not the connection. Force-closing isn't warranted (the link is fine),
// but the fast path skips anything with sentAt!==0, so the stranded
// frame would never re-send. Reset sentAt=0 on every stale unacked
// frame so the _drainSession below re-drives them over the live socket
// (server dedups by seq, so a re-sent lost-ACK frame is harmless).
// Frames sent recently (not yet stale) are left untouched.
for ( const rec of list ) {
if ( rec . sentAt && Date . now () - rec . sentAt > this . _reliableAckTimeoutMs ) rec . sentAt = 0 ;
}
}
2026-06-19 16:44:18 +02:00
}
this . _drainSession ( sessionId );
}
}
/** Total bytes/count still awaiting ACK across all sessions (for the indicator). */
_pendingBytes () {
let bytes = 0 ;
let count = 0 ;
for ( const list of this . _pendingDeliveries . values ()) {
for ( const r of list ) {
bytes += r . data . length ;
count ++ ;
}
}
return { bytes , count };
}
// ---- durable persistence (localStorage; quota- and disabled-storage-safe) --
_loadReliableState () {
// Stable client identity for server-side dedup across reconnects/reloads.
try {
this . _clientId = localStorage . getItem ( 'codeman:clientId' ) || '' ;
} catch {
this . _clientId = '' ;
}
if ( ! this . _clientId ) {
this . _clientId = 'c-' + Math . random (). toString ( 36 ). slice ( 2 ) + '-' + Date . now (). toString ( 36 );
try {
localStorage . setItem ( 'codeman:clientId' , this . _clientId );
} catch {
/* storage disabled — dedup degrades to per-load, still no loss */
}
}
try {
const raw = localStorage . getItem ( 'codeman:pendingInput' );
if ( ! raw ) return ;
const saved = JSON . parse ( raw );
if ( saved && saved . seqs ) {
for ( const [ s , n ] of Object . entries ( saved . seqs )) {
if ( Number . isFinite ( n )) this . _seqCounters . set ( s , n );
}
}
if ( saved && saved . pending ) {
for ( const [ s , recs ] of Object . entries ( saved . pending )) {
if ( Array . isArray ( recs ) && recs . length ) {
// Reset sentAt so they re-deliver promptly on this fresh load.
this . _pendingDeliveries . set (
s ,
recs
. filter (( r ) => r && typeof r . data === 'string' && Number . isInteger ( r . seq ))
. map (( r ) => ({
seq : r . seq ,
data : r . data ,
useMux : !! r . useMux ,
ts : r . ts || Date . now (),
tries : 0 ,
sentAt : 0 ,
}))
);
}
}
}
} catch {
/* corrupt/parse error — start clean rather than throw */
}
}
_persistReliableState () {
// Debounced — typing without local echo calls this per keystroke.
if ( this . _persistReliableTimer ) return ;
this . _persistReliableTimer = setTimeout (() => {
this . _persistReliableTimer = null ;
this . _persistReliableNow ();
}, 250 );
}
_persistReliableNow () {
if ( this . _persistReliableTimer ) {
clearTimeout ( this . _persistReliableTimer );
this . _persistReliableTimer = null ;
}
try {
const seqs = {};
for ( const [ s , n ] of this . _seqCounters ) seqs [ s ] = n ;
const pending = {};
let bytes = 0 ;
for ( const [ s , list ] of this . _pendingDeliveries ) {
if ( ! list . length ) continue ;
pending [ s ] = list . map (( r ) => ({
seq : r . seq ,
data : r . data ,
useMux : r . useMux ,
ts : r . ts ,
tries : r . tries ,
}));
for ( const r of list ) bytes += r . data . length ;
}
// Bound the persisted backlog. On extreme overflow keep the seq counters
// (so future input stays monotonic and dedup-safe) but skip the payloads —
// the in-memory queue still delivers; only cross-reload durability is lost.
const payload =
bytes > this . _reliableMaxBytes ? { seqs } : { seqs , pending };
localStorage . setItem ( 'codeman:pendingInput' , JSON . stringify ( payload ));
} catch {
/* QuotaExceeded or disabled storage — in-memory delivery is unaffected */
}
2026-02-12 08:34:51 +01:00
}
2026-06-20 10:03:25 -04:00
// Pure render of the header connection indicator: reads only `this.*` state,
// touches NO DOM. Returns the exact { display, dotClass, text, title } tuple the
// writer applies. When hidden (display:'none') the other three are normalized to
// '' so the cache compare in _updateConnectionIndicator() is well-defined.
// Every branch/string here must stay byte-identical to what's rendered today.
_computeConnectionDescriptor () {
2026-06-19 19:45:29 -04:00
const { bytes : totalBytes , count } = this . _pendingBytes ();
const hasQueue = count > 0 ;
// Only surface a backlog once it's more than a few bytes. A single keystroke
// (1B) ACKs in milliseconds, so without this the label flickered "sending 1B"
// on every key press. Above this threshold means input is genuinely backing up.
const BACKLOG_HINT_BYTES = 4 ;
const showBacklog = totalBytes > BACKLOG_HINT_BYTES ;
const formatBytes = ( b ) => ( b < 1024 ? ` ${ b } B` : ` ${ ( b / 1024 ). toFixed ( 1 ) } KB` );
const queuedSuffix = showBacklog ? ` · ${ formatBytes ( totalBytes ) } queued` : '' ;
2026-02-12 08:34:51 +01:00
2026-06-19 19:45:29 -04:00
// Hard offline (browser reports no network) dominates everything.
if ( ! this . isOnline || this . _connectionStatus === 'offline' ) {
2026-06-20 10:03:25 -04:00
return {
display : 'flex' ,
dotClass : 'connection-dot offline' ,
text : showBacklog ? `Offline ( ${ formatBytes ( totalBytes ) } queued)` : 'Offline' ,
title : 'No network connection' ,
};
2026-02-12 08:34:51 +01:00
}
2026-06-19 19:45:29 -04:00
// With an active terminal, show its transport (WebSocket vs HTTP fallback).
if ( this . activeSessionId ) {
let cls , label , detail ;
switch ( this . _wsState ) {
case 'connected' :
cls = 'connected' ; label = 'WS' ; detail = 'Terminal connected over WebSocket' ;
break ;
case 'fallback' :
cls = 'fallback' ; label = 'HTTP' ; detail = 'WebSocket unavailable — input sent over HTTP' ;
break ;
case 'reconnecting' :
cls = 'reconnecting' ; label = 'WS…' ; detail = 'Reconnecting WebSocket' ;
break ;
case 'connecting' :
default :
cls = 'reconnecting' ; label = 'WS…' ; detail = 'Connecting WebSocket' ;
break ;
}
2026-06-20 10:03:25 -04:00
return {
display : 'flex' ,
dotClass : `connection-dot ${ cls } ` ,
text : ` ${ label }${ queuedSuffix } ` ,
title : detail ,
};
2026-02-12 08:34:51 +01:00
}
2026-06-19 19:45:29 -04:00
// No active terminal — reflect the SSE event stream only when it needs attention.
if ( this . _connectionStatus === 'reconnecting' || this . _connectionStatus === 'disconnected' ) {
2026-06-20 10:03:25 -04:00
return {
display : 'flex' ,
dotClass : 'connection-dot reconnecting' ,
text : showBacklog ? `Reconnecting ( ${ formatBytes ( totalBytes ) } queued)` : 'Reconnecting...' ,
title : 'Reconnecting to server' ,
};
2026-06-19 19:45:29 -04:00
}
// Idle dashboard, healthy stream — hide unless input is genuinely queued.
if ( ! hasQueue ) {
2026-06-20 10:03:25 -04:00
return { display : 'none' , dotClass : '' , text : '' , title : '' };
}
return {
display : 'flex' ,
dotClass : 'connection-dot draining' ,
text : showBacklog ? `Sending ${ formatBytes ( totalBytes ) } ...` : 'Sending...' ,
title : 'Delivering queued input' ,
};
}
2026-02-12 08:34:51 +01:00
_updateConnectionIndicator () {
const indicator = this . $ ( 'connectionIndicator' );
const dot = this . $ ( 'connectionDot' );
const text = this . $ ( 'connectionText' );
if ( ! indicator || ! dot || ! text ) return ;
2026-06-20 10:03:25 -04:00
// Called on EVERY keystroke (_reliableSend) and EVERY ACK (_ackDelivery).
// During fast typing the rendered tuple is usually identical, so skip the DOM
// writes when nothing changed (COD-136) — the compute above is DOM-free.
const next = this . _computeConnectionDescriptor ();
const prev = this . _lastIndicatorDescriptor ;
if (
prev &&
prev . display === next . display &&
prev . dotClass === next . dotClass &&
prev . text === next . text &&
prev . title === next . title
) {
2026-02-12 08:34:51 +01:00
return ;
}
2026-06-20 10:03:25 -04:00
this . _lastIndicatorDescriptor = next ;
2026-02-12 08:34:51 +01:00
2026-06-20 10:03:25 -04:00
indicator . style . display = next . display ;
if ( next . display !== 'none' ) {
dot . className = next . dotClass ;
text . textContent = next . text ;
indicator . title = next . title ;
2026-02-12 08:34:51 +01:00
}
2026-01-30 13:25:18 +01:00
}
setupOnlineDetection () {
window . addEventListener ( 'online' , () => {
this . isOnline = true ;
this . reconnectAttempts = 0 ;
this . connectSSE ();
2026-06-19 16:44:18 +02:00
// Network came back — drain durably-queued input right away.
this . _redeliverSweep ();
2026-01-30 13:25:18 +01:00
});
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 ();
2026-06-09 10:26:09 -04:00
const defaults = this . getDefaultSettings ? .() || {};
2026-06-10 04:31:45 +02:00
// Mobile defaults ship cjkInputEnabled: false (native terminal input by
// default on touch), but an explicit user enable is honored everywhere —
// the App Settings toggle must not be a silent no-op on phones.
2026-06-13 18:49:57 +08:00
// The welcome/home screen (no active session) has nothing to type into.
// Force-hide the CJK textarea there — otherwise the `position: fixed`
// `.cjk-input-visible` rule floats it over the welcome overlay and blocks
// content. Re-synced on session enter/leave via hideWelcome()/showWelcome().
const cjkUserEnabled =
this . _serverCjkOverride || ( settings . cjkInputEnabled ?? defaults . cjkInputEnabled ?? false );
const showCjk = cjkUserEnabled && !! this . activeSessionId ;
2026-06-09 10:26:09 -04:00
cjkEl . classList . toggle ( 'cjk-input-visible' , !! showCjk );
document . body . classList . toggle ( 'cjk-input-visible' , !! showCjk );
2026-03-24 09:22:06 +08:00
cjkEl . style . display = showCjk ? 'block' : 'none' ;
2026-06-09 10:26:09 -04:00
cjkEl . setAttribute ( 'aria-hidden' , showCjk ? 'false' : 'true' );
2026-03-24 09:22:06 +08:00
if ( ! showCjk ) window . cjkActive = false ;
2026-06-09 10:26:09 -04:00
if ( typeof KeyboardHandler !== 'undefined' ) KeyboardHandler . updateLayoutForKeyboard ();
2026-03-24 09:22:06 +08:00
}
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-06-10 19:56:20 -04:00
this . _xtermSnapshots ? . 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-06-09 10:38:35 -04:00
this . _bufferLoadOwner = 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-06-15 08:40:05 +02:00
// Clear ultracode workflow run state (re-seeded from data.workflowRuns below)
if ( this . workflowRuns ) this . workflowRuns . clear ();
if ( this . workflowRunDetails ) this . workflowRunDetails . clear ();
this . activeWorkflowRunId = null ;
this . activeWorkflowPhaseIndex = null ;
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 ();
2026-06-12 14:37:27 -04:00
this . attachmentHistoryCounts . clear ();
2026-01-27 17:46:43 +01:00
// 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 ();
2026-06-14 06:15:34 +02:00
// Plan-usage chip: server's last-known telemetry, so it shows immediately on
// a fresh load / reconnect (authoritative; wins over the localStorage restore).
if ( data . planUsage ) this . updatePlanUsageChip ( data . planUsage );
2026-03-25 23:32:28 +01:00
// 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-05-19 22:19:07 +08:00
// Server is source of truth for open sessions — don't resurrect stale tabs
// from localStorage (would show phantom "ended" tabs when a session was closed
// on another device).
try { localStorage . removeItem ( 'codeman-tab-meta' ); } catch {}
2026-03-10 14:30:21 +08:00
2026-06-19 18:42:39 -04:00
// COD-131: server is authoritative for global tab order. Seed localStorage
// from the server snapshot (if present) so syncSessionOrder() reconciles
// against the cross-device order rather than this device's stale local copy.
if ( Array . isArray ( data . sessionOrder ) && data . sessionOrder . length ) {
try { localStorage . setItem ( 'codeman-session-order' , JSON . stringify ( data . sessionOrder )); } catch {}
}
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-06-15 08:40:05 +02:00
// Seed ultracode workflow runs (LEFT-pane summaries) from the snapshot
if ( data . workflowRuns ) {
this . seedWorkflowRuns ( data . workflowRuns );
}
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-06-06 03:53:54 +02:00
// Solo (detached) window: always show exactly the target session, ignoring
// the dashboard's "restore last active" logic.
if ( this . isSoloWindow ) {
this . _applySoloMode ();
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 ();
2026-06-10 01:30:43 +02:00
this . handleInit ( data ? . data ?? {});
2026-01-18 05:14:34 +01:00
} 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-05-12 03:10:34 -04:00
// Don't re-render while user is typing in the inline rename input
2026-06-09 10:38:35 -04:00
if ( this . _inlineRenameActive ) return ;
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-06-09 10:38:35 -04:00
_setTerminalLoadState ( sessionId , selectGen , phase ) {
this . terminalLoadStates . set ( sessionId , { generation : selectGen , phase });
this . _updateTerminalLoadTab ( sessionId );
}
_clearTerminalLoadState ( sessionId , selectGen ) {
const state = this . terminalLoadStates . get ( sessionId );
if ( state && state . generation !== selectGen ) return ;
this . terminalLoadStates . delete ( sessionId );
this . _updateTerminalLoadTab ( sessionId );
}
_updateTerminalLoadTab ( sessionId ) {
const tab = this . $ ( 'sessionTabs' ) ? . querySelector ( `.session-tab[data-id=" ${ sessionId } "]` );
if ( ! tab ) return ;
const loadState = this . terminalLoadStates . get ( sessionId );
tab . classList . toggle ( 'tab-loading' , !! loadState );
if ( loadState ) {
tab . setAttribute ( 'aria-busy' , 'true' );
tab . dataset . loadPhase = loadState . phase ;
if ( ! tab . querySelector ( '.tab-load-spinner' )) {
const spinner = document . createElement ( 'span' );
spinner . className = 'tab-load-spinner' ;
spinner . setAttribute ( 'aria-hidden' , 'true' );
const numberEl = tab . querySelector ( '.tab-number' );
if ( numberEl ) {
numberEl . insertAdjacentElement ( 'afterend' , spinner );
} else {
tab . insertBefore ( spinner , tab . firstChild );
}
}
} else {
tab . setAttribute ( 'aria-busy' , 'false' );
delete tab . dataset . loadPhase ;
tab . querySelector ( '.tab-load-spinner' ) ? . remove ();
}
}
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 ;
2026-06-09 10:38:35 -04:00
const loadState = this . terminalLoadStates . get ( id );
2026-01-21 06:06:57 +01:00
// 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-06-09 10:38:35 -04:00
tab . classList . toggle ( 'tab-loading' , !! loadState );
if ( loadState ) {
tab . setAttribute ( 'aria-busy' , 'true' );
tab . dataset . loadPhase = loadState . phase ;
if ( ! tab . querySelector ( '.tab-load-spinner' )) {
const spinner = document . createElement ( 'span' );
spinner . className = 'tab-load-spinner' ;
spinner . setAttribute ( 'aria-hidden' , 'true' );
const numberEl = tab . querySelector ( '.tab-number' );
if ( numberEl ) {
numberEl . insertAdjacentElement ( 'afterend' , spinner );
} else {
tab . insertBefore ( spinner , tab . firstChild );
}
}
} else {
tab . setAttribute ( 'aria-busy' , 'false' );
delete tab . dataset . loadPhase ;
tab . querySelector ( '.tab-load-spinner' ) ? . remove ();
}
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-04-12 10:04:34 -04:00
// Inject tab-number badge if missing (added after initial render)
if ( ! tab . querySelector ( '.tab-number' )) {
const idx = this . sessionOrder . indexOf ( id );
if ( idx >= 0 && idx < 9 ) {
const numSpan = document . createElement ( 'span' );
numSpan . className = 'tab-number' ;
numSpan . textContent = String ( idx + 1 );
tab . insertBefore ( numSpan , tab . firstChild );
}
}
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 ();
}
2026-06-14 15:49:12 -04:00
this . updateTabOverflowMode ();
}
// Auto-wrap desktop session tabs to a second row when they overflow one row,
// unless the user has pinned the manual two-row layout (tabTwoRows). Mobile/
// tablet keep horizontal scroll. Policy lives in constants.js for unit testing.
updateTabOverflowMode () {
const container = this . $ ( 'sessionTabs' );
if ( ! container ) return ;
const deviceType = MobileDetection . getDeviceType ();
const settings = this . loadAppSettingsFromStorage ();
const defaults = this . getDefaultSettings ();
const manualTwoRows = deviceType === 'desktop' ? ( settings . tabTwoRows ?? defaults . tabTwoRows ?? false ) : false ;
if ( manualTwoRows || deviceType !== 'desktop' ) {
container . classList . remove ( 'tabs-auto-wrap' );
return ;
}
// Measure the natural one-row overflow, then enable wrapping only if needed.
container . classList . remove ( 'tabs-auto-wrap' );
const shouldWrap = window . CodemanTabOverflow ? . shouldAutoWrapTabs
? window . CodemanTabOverflow . shouldAutoWrapTabs ({
deviceType ,
manualTwoRows ,
tabCount : this . sessions . size ,
scrollWidth : container . scrollWidth ,
clientWidth : container . clientWidth ,
})
: container . scrollWidth > container . clientWidth + 1 ;
container . classList . toggle ( 'tabs-auto-wrap' , shouldWrap );
2026-01-21 06:06:57 +01:00
}
_fullRenderSessionTabs () {
2026-06-09 10:38:35 -04:00
if ( this . _inlineRenameActive ) return ;
2026-01-21 06:06:57 +01:00
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 )];
}
2026-04-12 10:04:34 -04:00
let _tabIdx = 0 ;
2026-01-31 04:44:46 +01:00
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-06-09 10:38:35 -04:00
const loadState = this . terminalLoadStates . get ( id );
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-06-18 21:26:33 +02:00
// Ultracode runs + agent transcripts minimized to this tab (ultracode-windows.js
// renders one merged ULTRA badge; returns '' when nothing is minimized).
const ultracodeBadge = this . renderUltracodeTabBadge ? this . renderUltracodeTabBadge ( id ) : '' ;
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-06-19 15:26:42 +02:00
parts . push ( `<div class="session-tab ${ isActive ? 'active' : '' }${ alertClass }${ loadState ? ' tab-loading' : '' } " data-id=" ${ id } " data-color=" ${ color } " ${ loadState ? `data-load-phase=" ${ escapeHtml ( loadState . phase ) } "` : '' } onclick="app.handleSessionTabClick(event, ${ escapeHtml ( JSON . stringify ( id )) } )" oncontextmenu="event.preventDefault(); app.startInlineRename( ${ escapeHtml ( JSON . stringify ( id )) } )" tabindex="0" role="tab" aria-selected=" ${ isActive ? 'true' : 'false' } " aria-busy=" ${ loadState ? 'true' : 'false' } " aria-label=" ${ escapeHtml ( name ) } session" ${ session . workingDir ? `title=" ${ escapeHtml ( session . workingDir ) } "` : '' } >
2026-04-12 10:04:34 -04:00
${ _tabIdx < 9 ? '<span class="tab-number">' + ( _tabIdx + 1 ) + '</span>' : '' }
2026-06-09 10:38:35 -04:00
${ loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : '' }
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-06-25 00:26:06 +02: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>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</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-06-06 03:53:54 +02:00
<span class="tab-detached-badge" aria-hidden="true">detached</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-06-18 21:26:33 +02:00
${ ultracodeBadge }
2026-06-19 15:26:42 +02:00
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions( ${ escapeHtml ( JSON . stringify ( id )) } )" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession( ${ escapeHtml ( JSON . stringify ( id )) } )" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span>
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession( ${ escapeHtml ( JSON . stringify ( id )) } )" title="Close session" aria-label="Close session" tabindex="0">×</span>
2026-01-19 11:48:02 +01:00
</div>` );
2026-04-12 10:04:34 -04:00
_tabIdx ++ ;
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
// 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-06-14 22:38:19 +02:00
// Re-evaluate desktop auto-wrap for every full rebuild, including the incremental
// branch's early `_fullRenderSessionTabs(); return;` paths and the manual two-rows
// toggle (applyTabWrapSettings calls this) which would otherwise leave a stale
// tabs-auto-wrap class until the next content render.
this . updateTabOverflowMode ();
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 ;
2026-06-09 10:38:35 -04:00
this . selectSession ( sessionId , { forceReload : true });
2026-01-30 13:25:18 +01:00
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-06-09 10:26:09 -04:00
handleSessionTabClick ( event , sessionId ) {
event ? . preventDefault ? .();
2026-06-10 04:31:45 +02:00
// On touch with the keyboard hidden, blur the tapped tab so switching
// sessions doesn't pop the on-screen keyboard. Focus policy itself lives
// in selectSession via _shouldFocusTerminalForTabSwitch().
const keyboardOpen = typeof KeyboardHandler !== 'undefined' && KeyboardHandler . keyboardVisible === true ;
if ( ! keyboardOpen && MobileDetection . isTouchDevice ()) {
2026-06-09 10:26:09 -04:00
document . activeElement ? . blur ? .();
}
2026-06-10 04:31:45 +02:00
return this . selectSession ( sessionId , { forceReload : true });
2026-06-09 10:26:09 -04:00
}
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 [];
}
}
2026-06-19 18:42:39 -04:00
// Save session order to localStorage and (debounced) sync to the server so it
// follows the user across devices (COD-131). localStorage stays the offline
// fallback; the server is authoritative and echoes back via SSE.
2026-01-29 12:47:23 +01:00
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-06-19 18:42:39 -04:00
const order = [... this . sessionOrder ];
this . _debouncedCall ( 'saveSessionOrderServer' , () => {
fetch ( '/api/session-order' , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ order }),
}). catch (() => {});
}, 400 );
}
// COD-131: another device (or our own debounced push) reordered tabs. Adopt the
// server order as the new base and reconcile to our currently-open sessions.
// Guard against no-op churn so an echo of our own push doesn't flicker the tabs.
_onSessionOrderChanged ( data ) {
if ( ! data || ! Array . isArray ( data . order )) return ;
try {
localStorage . setItem ( 'codeman-session-order' , JSON . stringify ( data . order ));
} catch {
// Ignore storage errors
}
const before = JSON . stringify ( this . sessionOrder );
this . syncSessionOrder ();
// Only re-render when the reconciled order actually changed (avoids flicker
// when the broadcast is just an echo of the order we already have).
if ( JSON . stringify ( this . sessionOrder ) !== before ) {
this . _fullRenderSessionTabs ();
}
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-04-12 10:02:12 -04:00
moveActiveTabLeft () {
if ( ! this . activeSessionId ) return ;
const idx = this . sessionOrder . indexOf ( this . activeSessionId );
if ( idx <= 0 ) return ;
[ this . sessionOrder [ idx - 1 ], this . sessionOrder [ idx ]] = [ this . sessionOrder [ idx ], this . sessionOrder [ idx - 1 ]];
this . saveSessionOrder ();
this . _fullRenderSessionTabs ();
}
moveActiveTabRight () {
if ( ! this . activeSessionId ) return ;
const idx = this . sessionOrder . indexOf ( this . activeSessionId );
if ( idx === - 1 || idx >= this . sessionOrder . length - 1 ) return ;
[ this . sessionOrder [ idx ], this . sessionOrder [ idx + 1 ]] = [ this . sessionOrder [ idx + 1 ], this . sessionOrder [ idx ]];
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.
*/
2026-06-10 19:56:20 -04:00
_isUsableXtermSnapshot ( snapshot ) {
if ( ! snapshot || typeof snapshot !== 'string' || snapshot . length < 8 ) return false ;
const visibleText = snapshot
2026-06-11 10:16:47 +02:00
. replace ( /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g , '' )
2026-06-10 19:56:20 -04:00
. replace ( /\x1b\[[0-9;?]*[ -/]*[@-~]/g , '' )
. replace ( /\x1b[()][0-2A-Z]/g , '' )
. replace ( /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g , '' )
. trim ();
return visibleText . length >= 3 ;
}
2026-06-11 10:16:47 +02:00
/**
* Persist one xterm snapshot to localStorage, bounded to a fixed key budget
* regardless of how many sessions are live, and resilient to quota errors.
* The previous inline version only pruned snapshots for sessions that no
* longer existed AND pruned only after a successful setItem — so once the
* quota filled (e.g. >10 live sessions at the 20-session target) the write
* threw before the prune could run, permanently disabling persistence.
*/
_persistXtermSnapshot ( key , snapshot ) {
const PREFIX = 'codeman-xs-' ;
const MAX_KEYS = 10 ;
const others = () => Object . keys ( localStorage ). filter (( k ) => k . startsWith ( PREFIX ) && k !== key );
try {
// Evict down to the budget before writing a NEW key, dead sessions first
// then oldest. (Overwriting an existing key doesn't grow the key count.)
if ( localStorage . getItem ( key ) === null ) {
const live = new Set ( Array . from ( this . sessions ? . keys ? .() || []));
const pool = others (). sort (
( a , b ) =>
Number ( live . has ( a . slice ( PREFIX . length ))) - Number ( live . has ( b . slice ( PREFIX . length )))
);
while ( pool . length >= MAX_KEYS ) localStorage . removeItem ( pool . shift ());
}
try {
localStorage . setItem ( key , snapshot );
} catch ( _quota ) {
// Quota exceeded: drop other snapshots one at a time and retry so a full
// quota can't permanently disable persistence.
for ( const victim of others ()) {
localStorage . removeItem ( victim );
try {
localStorage . setItem ( key , snapshot );
return ;
} catch ( _again ) {
/* keep evicting */
}
}
try { localStorage . removeItem ( key ); } catch {}
}
} catch ( _unavailable ) {
/* localStorage unavailable (Safari private mode / disabled) — in-memory only */
}
}
2026-03-25 23:32:28 +01:00
_cleanupPreviousSession ( newSessionId ) {
2026-06-10 19:56:20 -04:00
// Snapshot the OUTGOING session's xterm rendered state (viewport + scrollback +
// colors/attrs) before the terminal gets cleared/reset. Lets us restore the
// exact view on switch-back rather than replaying codex's byte stream, which
// drops earlier conversation from each TUI redraw and ends up showing only
// the latest (idle) frame.
2026-06-11 10:16:47 +02:00
// Shell sessions are never restored from a snapshot (restore is gated on
// mode !== 'shell'), so skip the serialize() + cache slot + localStorage
// quota for them. Unknown/undefined mode still snapshots, matching restore.
const outgoingSession = this . activeSessionId ? this . sessions ? . get ? .( this . activeSessionId ) : null ;
if (
this . activeSessionId &&
outgoingSession ? . mode !== 'shell' &&
this . _serializeAddon &&
this . _xtermSnapshots
) {
2026-06-10 19:56:20 -04:00
try {
const snapshot = this . _serializeAddon . serialize ({ scrollback : 1000 });
if ( this . _isUsableXtermSnapshot ( snapshot )) {
2026-06-11 10:16:47 +02:00
// Delete-before-set so re-touching a session moves it to the end of
// the Map's insertion order — otherwise eviction is FIFO and can drop
// the most-recently-used session instead of the least.
this . _xtermSnapshots . delete ( this . activeSessionId );
2026-06-10 19:56:20 -04:00
this . _xtermSnapshots . set ( this . activeSessionId , snapshot );
// Cap in-memory snapshot cache at 20 entries; evict oldest on overflow.
if ( this . _xtermSnapshots . size > 20 ) {
const oldest = this . _xtermSnapshots . keys (). next (). value ;
this . _xtermSnapshots . delete ( oldest );
}
// Persist to localStorage so the snapshot survives tab discard /
// browser reload (Chrome discards inactive tabs after idle periods,
2026-06-11 10:16:47 +02:00
// wiping in-memory state). Cap per-snapshot at 256KB; codex
// buffer-replay produces a visual mess of stacked banner redraws when
// no snapshot exists, so persistence matters more here than for claude.
2026-06-10 19:56:20 -04:00
if ( snapshot . length < 256 * 1024 ) {
2026-06-11 10:16:47 +02:00
this . _persistXtermSnapshot ( `codeman-xs- ${ this . activeSessionId } ` , snapshot );
2026-06-10 19:56:20 -04:00
}
} else {
this . _xtermSnapshots . delete ( this . activeSessionId );
try { localStorage . removeItem ( `codeman-xs- ${ this . activeSessionId } ` ); } catch {}
}
} catch ( _err ) {
/* Serialize failed — fall back to server buffer replay */
}
}
2026-03-12 21:02:28 -04:00
// Close WebSocket for previous session (new one opens after buffer load)
this . _disconnectWs ();
2026-07-06 16:15:26 +08:00
// Clear CJK input to prevent sending stale text to the wrong session.
// Must go through CjkInput.clear() — a raw value wipe leaves the module's
// pending flush timers armed and drops the phantom char it relies on.
if ( typeof CjkInput !== 'undefined' ) {
CjkInput . clear ();
} else {
const cjkEl = document . getElementById ( 'cjkInput' );
if ( cjkEl ) cjkEl . value = '' ;
}
2026-03-14 18:37:10 +01:00
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-06-09 10:38:35 -04:00
this . _bufferLoadOwner = 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
}
2026-06-09 10:38:35 -04:00
_resetTerminalForReplay () {
this . terminal . reset ();
this . terminal . write ( '\x1b[3J\x1b[H\x1b[2J' );
}
2026-06-09 10:26:09 -04:00
_shouldFocusTerminalForTabSwitch () {
if ( typeof MobileDetection === 'undefined' || ! MobileDetection . isTouchDevice ()) {
return true ;
}
return typeof KeyboardHandler !== 'undefined' && KeyboardHandler . keyboardVisible ;
}
2026-06-09 10:38:35 -04:00
async selectSession ( sessionId , options = {}) {
2026-06-06 03:53:54 +02:00
// If this session is popped out into its own window, raise that window
2026-06-09 10:38:35 -04:00
// instead of showing it inline (focus-on-click for detached tabs). If we
// owned a now-closed window, _raiseDetached re-docks and returns false so
// we fall through and load it inline.
2026-06-06 03:53:54 +02:00
if ( ! this . isSoloWindow && this . detachedSessions . has ( sessionId )) {
2026-06-06 04:14:40 +02:00
if ( this . _raiseDetached ( sessionId )) return ;
2026-06-06 03:53:54 +02:00
}
2026-06-09 10:38:35 -04:00
const forceReload = options ? . forceReload === true ;
if ( this . activeSessionId === sessionId && ! forceReload ) return ;
if ( this . activeSessionId === sessionId && forceReload ) {
this . terminalBufferCache ? . delete ( sessionId );
2026-06-10 19:56:20 -04:00
this . _xtermSnapshots ? . delete ( sessionId );
try { localStorage . removeItem ( `codeman-xs- ${ sessionId } ` ); } catch {}
2026-06-09 10:38:35 -04:00
this . _clearTimer ( 'syncWaitTimeout' );
this . pendingWrites = [];
this . writeFrameScheduled = false ;
this . _isLoadingBuffer = false ;
this . _loadBufferQueue = null ;
this . _chunkedWriteGen = ( this . _chunkedWriteGen || 0 ) + 1 ;
this . activeSessionId = null ;
}
2026-03-25 23:32:28 +01:00
// 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.
2026-06-10 04:31:45 +02:00
// Desktop always focuses; touch focuses only while the on-screen keyboard
// is already open (so a tab switch doesn't pop the keyboard).
const shouldFocusTerminal = this . _shouldFocusTerminalForTabSwitch ();
2026-06-09 10:26:09 -04:00
if ( shouldFocusTerminal && this . terminal ) this . terminal . focus ();
2026-03-25 23:32:28 +01:00
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 ;
2026-06-09 10:38:35 -04:00
this . _setTerminalLoadState ( sessionId , selectGen , 'resizing' );
2026-03-25 23:32:28 +01:00
2026-06-09 10:38:35 -04:00
if ( selectGen !== this . _selectGeneration ) {
this . _clearTerminalLoadState ( sessionId , selectGen );
return ; // newer tab switch won
}
2026-03-25 23:32:28 +01:00
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-05-16 23:56:53 -04:00
// Narrow SSE filter to the active session — server stops streaming
// session:terminal events for other sessions to this client. Cuts
// SSE traffic ~Nx for N concurrent sessions. Fire-and-forget; on the
// rare race where server doesn't know our clientId yet, the next
// selectSession or reconnect catches up.
this . _updateSseSubscription ( sessionId );
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-06-12 14:37:27 -04:00
this . updateAttachmentHistoryBadge ? .();
if ( this . attachmentHistoryDrawerOpen ) {
this . loadAttachmentHistory ? .( sessionId );
}
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
// Track working directory for path normalization in Project Insights
this . currentSessionWorkingDir = session ? . workingDir || null ;
2026-05-19 22:19:07 +08:00
if ( session && session . pid === null ) {
2026-07-12 18:21:42 +02:00
if ( session . respawnBlocked ) {
// COD-118: the PTY-exit circuit breaker tripped for this session — the
// automatic re-attach must NOT silently clear it (that would re-arm the
// crash loop on every tab click / page load). Restart only on explicit
// user confirmation; the confirmed request carries clearBreaker:true.
const label = session . name || 'Session' ;
if ( window . confirm ( ` ${ label } was stopped after crashing repeatedly. Restart it?` )) {
try {
await fetch ( `/api/sessions/ ${ sessionId } /interactive` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ clearBreaker : true }),
});
session . respawnBlocked = false ;
session . status = 'busy' ;
} catch ( err ) {
console . error ( 'Failed to restart crash-looped session:' , err );
}
}
} else {
// Session has no PTY attached — either restored after server restart
// or detached for some other reason. Re-attach regardless of status.
// Deliberately NO body: this automatic path must never clear a tripped
// PTY-exit breaker (COD-118).
try {
const endpoint = session . mode === 'shell'
? `/api/sessions/ ${ sessionId } /shell`
: `/api/sessions/ ${ sessionId } /interactive` ;
await fetch ( endpoint , { method : 'POST' });
// Update local session state
session . status = 'busy' ;
} catch ( err ) {
console . error ( 'Failed to attach to restored session:' , err );
}
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.
2026-06-09 10:38:35 -04:00
const bufferLoadOwner = this . _beginBufferLoad ( selectGen );
2026-06-21 14:47:21 -04:00
// COD-144: track whether the load painted nothing (empty fetch + no cache).
// For that just-created-session case we flush (not discard) queued SSE events.
let bufferWasEmpty = false ;
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-06-09 10:38:35 -04:00
// Also push the new dimensions to the PTY. Without this, codex/codeman
// sees the size that was set the last time the throttled resize handler
// fired (often the size of a different session's container, or the
// initial tmux default). The visible symptom is codex rendering inside
// a small region with empty rows below the status bar.
// sendResize is a no-op on the server when dims haven't changed, so
// calling it every tab switch is cheap.
2026-06-10 02:07:26 +02:00
const dimsChanged = await this . sendResize ( sessionId , { forceHttp : true }). catch (() => false );
2026-06-09 10:38:35 -04:00
if ( this . _isStaleSelect ( selectGen )) {
this . _clearTerminalLoadState ( sessionId , selectGen );
return ;
}
2026-06-10 19:56:20 -04:00
// xterm snapshot restore: if we have a serialized xterm state from a
// previous visit to this session, restore the user's exact prior view
// (viewport + scrollback + colors) for an instant first paint. For codex
// this is also a correctness fix — its byte-stream replay shows only the
// latest TUI frame (the idle welcome banner) because codex doesn't include
// earlier conversation in its current redraw. For claude/opencode/gemini
// the replay is already complete, so the snapshot is purely a faster,
// scroll-preserving first paint before the canonical fetch reconciles.
//
// Try in-memory first (fast); fall back to localStorage so snapshots
// survive tab discards / browser reloads.
let snapshot = this . _xtermSnapshots ? . get ( sessionId );
if ( snapshot && ! this . _isUsableXtermSnapshot ( snapshot )) {
this . _xtermSnapshots ? . delete ( sessionId );
snapshot = null ;
}
if ( ! snapshot ) {
try {
const persisted = localStorage . getItem ( `codeman-xs- ${ sessionId } ` );
if ( persisted && this . _isUsableXtermSnapshot ( persisted )) {
snapshot = persisted ;
2026-06-11 10:16:47 +02:00
// Hoist into in-memory cache for next time (delete-before-set keeps
// the Map in LRU order so the just-used session isn't evicted first).
this . _xtermSnapshots ? . delete ( sessionId );
2026-06-10 19:56:20 -04:00
this . _xtermSnapshots ? . set ( sessionId , persisted );
} else if ( persisted ) {
localStorage . removeItem ( `codeman-xs- ${ sessionId } ` );
}
} catch ( _e ) {
/* localStorage unavailable — proceed without snapshot */
}
}
2026-06-09 10:38:35 -04:00
const sessionIsBusy = session && ( session . status === 'busy' || session . status === 'working' );
2026-06-10 19:56:20 -04:00
let restoredSnapshot = false ;
if ( snapshot && ! sessionIsBusy && session ? . mode !== 'shell' ) {
_crashDiag . log ( `SNAPSHOT_RESTORE: ${ ( snapshot . length / 1024 ). toFixed ( 0 ) } KB` );
this . _setTerminalLoadState ( sessionId , selectGen , 'replaying' );
this . _resetTerminalForReplay ();
await new Promise (( resolve ) => this . terminal . write ( snapshot , resolve ));
if ( this . _isStaleSelect ( selectGen )) {
this . _clearTerminalLoadState ( sessionId , selectGen );
return ;
}
this . scrollToLastNonEmptyLine ();
_crashDiag . log ( 'SNAPSHOT_RESTORE_DONE' );
// Snapshot restore is only first paint. Inactive tabs intentionally
// unsubscribe from high-volume terminal output, so they can miss bytes
// emitted while away. Keep going and replace the snapshot with the
// canonical live tmux pane frame from /terminal.
restoredSnapshot = true ;
}
2026-06-09 10:38:35 -04:00
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-06-10 19:56:20 -04:00
let clearedForBusy = false ;
if ( cachedBuffer && ! sessionIsBusy && ! restoredSnapshot ) {
2026-03-05 16:44:17 +01:00
_crashDiag . log ( `CACHE_WRITE: ${ ( cachedBuffer . length / 1024 ). toFixed ( 0 ) } KB` );
2026-06-09 10:38:35 -04:00
this . _setTerminalLoadState ( sessionId , selectGen , 'replaying' );
this . _resetTerminalForReplay ();
await this . chunkedTerminalWrite ( cachedBuffer , TERMINAL_CHUNK_SIZE , bufferLoadOwner );
2026-06-10 02:07:26 +02:00
if ( this . _isStaleSelect ( selectGen )) {
this . _clearTerminalLoadState ( sessionId , 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
2026-06-09 10:38:35 -04:00
this . _resetTerminalForReplay ();
2026-06-10 19:56:20 -04:00
clearedForBusy = true ;
2026-03-23 12:32:33 +01:00
_crashDiag . log ( 'CACHE_SKIP_BUSY' );
2026-02-13 21:07:49 +01:00
}
2026-06-09 10:38:35 -04:00
// Give TUI sessions a short chance to redraw after resize before the
2026-06-10 02:07:26 +02:00
// fresh buffer fetch. Only needed when the resize actually changed
// dimensions (a real SIGWINCH → Ink redraw); a same-size tab switch sent
// no resize, so waiting would just add latency. Shell sessions never need
// it, so terminal content can appear immediately when switching shells.
if ( session ? . mode !== 'shell' && dimsChanged ) {
await new Promise (( resolve ) => setTimeout ( resolve , TUI_REDRAW_SETTLE_MS ));
2026-06-09 10:38:35 -04:00
if ( this . _isStaleSelect ( selectGen )) {
this . _clearTerminalLoadState ( sessionId , selectGen );
return ;
}
}
this . _setTerminalLoadState ( sessionId , selectGen , 'fetching' );
2026-03-05 16:44:17 +01:00
_crashDiag . log ( 'FETCH_START' );
2026-07-12 18:33:18 +02:00
// The FIRST buffer load after a page load requests the full tmux scrollback
// (?full=1, COD-47) so history that scrolled off the server's byte buffer
// comes back after a reload. Tab switches keep the fast ?tail= frame path.
const useFullHistory = this . _initialFullBufferLoad === true ;
this . _initialFullBufferLoad = false ;
const res = await fetch (
useFullHistory
? `/api/sessions/ ${ sessionId } /terminal?full=1`
: `/api/sessions/ ${ sessionId } /terminal?tail= ${ TERMINAL_TAIL_SIZE } `
);
2026-06-09 10:38:35 -04:00
if ( this . _isStaleSelect ( selectGen )) {
this . _clearTerminalLoadState ( sessionId , selectGen );
return ;
}
2026-06-10 01:30:43 +02:00
const data = ( await res . json ()) ? . data ?? {};
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.
2026-06-10 19:56:20 -04:00
// A snapshot restore or a busy-clear leaves the terminal showing
// something other than the cache, so the fetched buffer must be
// replayed even when it byte-matches the cache.
const needsRewrite =
restoredSnapshot || clearedForBusy || data . terminalBuffer !== cachedBuffer ;
2026-02-20 15:41:43 +01:00
if ( needsRewrite ) {
2026-03-05 16:44:17 +01:00
_crashDiag . log ( `REWRITE: ${ ( data . terminalBuffer . length / 1024 ). toFixed ( 0 ) } KB` );
2026-06-09 10:38:35 -04:00
this . _setTerminalLoadState ( sessionId , selectGen , 'replaying' );
this . _resetTerminalForReplay ();
2026-02-20 15:41:43 +01:00
// 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
2026-06-09 10:38:35 -04:00
await this . chunkedTerminalWrite ( data . terminalBuffer , TERMINAL_CHUNK_SIZE , bufferLoadOwner );
2026-06-10 02:07:26 +02:00
if ( this . _isStaleSelect ( selectGen )) {
this . _clearTerminalLoadState ( sessionId , 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
2026-06-09 10:38:35 -04:00
this . _resetTerminalForReplay ();
2026-06-21 14:47:21 -04:00
bufferWasEmpty = true ;
2026-02-02 05:14:18 +01:00
}
2026-06-21 14:47:21 -04:00
// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
// _finishBufferLoad internally (discarding queued events to prevent duplicate
// content); if we skipped the write (cache hit or empty), call it here.
// COD-144: when the load painted nothing, FLUSH the queued events instead of
// discarding — a new session's prompt arrives only as a queued SSE event.
2026-03-05 17:52:28 +01:00
if ( this . _isLoadingBuffer ) {
2026-06-21 14:47:21 -04:00
this . _finishBufferLoad ( bufferLoadOwner , { flushQueued : bufferWasEmpty });
2026-03-05 17:52:28 +01:00
}
// 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-05-26 06:12:43 +08:00
// Fire-and-forget resize to nudge Ink via SIGWINCH on real size changes.
// Previously we also sent Ctrl+L (\x0c) here to force a full Ink redraw,
// but Claude Code 2.x treats Ctrl+L as a two-step "clear conversation"
// command — if a page refresh or SSE reconnect ran selectSession twice
// within Claude's confirmation window, the second \x0c silently wiped the
// conversation. Stale Ink frames in the tailed buffer are a cosmetic
// annoyance that disappear on the user's next keypress; data loss is not
// acceptable. Do NOT re-introduce Ctrl+L here.
this . sendResize ( sessionId );
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-06-09 10:26:09 -04:00
if ( shouldFocusTerminal && this . terminal ) this . terminal . focus ();
2026-06-09 10:38:35 -04:00
this . scrollToLastNonEmptyLine ();
2026-06-26 01:52:32 +02:00
// If we switched INTO this tab while the soft keyboard is already up, no
// viewport-resize transition fires (handleViewportResize only runs
// onKeyboardShow on a hidden→visible change), so the newly-active
// session never gets that heal: fit() + scrollToBottom() + local-echo
// overlay rerender() + one-shot SIGWINCH. Without it the overlay renders
// against stale, off-bottom state and typed input stays INVISIBLE until
// the user manually toggles the keyboard. Replicate the heal here so
// local echo paints on the first keystroke after a keyboard-up tab switch.
if ( typeof KeyboardHandler !== 'undefined' && KeyboardHandler . keyboardVisible ) {
KeyboardHandler . onKeyboardShow ();
}
2026-06-09 10:38:35 -04:00
this . _clearTerminalLoadState ( sessionId , selectGen );
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-06-09 10:38:35 -04:00
if ( this . _isLoadingBuffer ) this . _finishBufferLoad ( bufferLoadOwner );
2026-02-22 02:49:19 +01:00
this . _restoringFlushedState = false ;
2026-06-09 10:38:35 -04:00
this . _setTerminalLoadState ( sessionId , selectGen , 'failed' );
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 ) {
2026-05-12 09:57:08 +02:00
// If the deleted session is currently being renamed, abort the rename
// so the inline <input> doesn't ghost as a stale tab on screen.
if ( this . _activeRename ? . sessionId === sessionId ) {
this . _activeRename . cancel ();
}
2026-02-15 05:26:29 +01:00
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-06-10 19:56:20 -04:00
this . _xtermSnapshots ? . delete ( sessionId );
try { localStorage . removeItem ( `codeman-xs- ${ sessionId } ` ); } catch {}
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-06-19 16:44:18 +02:00
// Drop any durably-queued input for a session that's actually gone (deleted/
// exited). Not a lost prompt — the target no longer exists. Only reached on
// real session removal, never on a tab switch.
this . _pendingDeliveries ? . delete ( sessionId );
this . _seqCounters ? . delete ( sessionId );
this . _postDraining ? . delete ( sessionId );
this . _persistReliableState ();
2026-02-15 05:26:29 +01:00
this . ralphStates . delete ( sessionId );
this . ralphClosedSessions . delete ( sessionId );
this . projectInsights . delete ( sessionId );
this . pendingHooks . delete ( sessionId );
this . tabAlerts . delete ( sessionId );
2026-06-12 14:37:27 -04:00
this . attachmentHistoryCounts . delete ( sessionId );
if ( this . attachmentHistoryDrawerOpen && this . activeSessionId === sessionId ) {
this . closeAttachmentHistory ? .();
}
2026-06-09 10:38:35 -04:00
this . terminalLoadStates . delete ( sessionId );
2026-02-15 05:26:29 +01:00
this . clearCountdownTimers ( sessionId );
this . closeSessionLogViewerWindows ( sessionId );
this . closeSessionImagePopups ( sessionId );
2026-06-12 09:09:14 -04:00
this . closeSessionAttachmentCards ( sessionId );
2026-02-15 05:26:29 +01:00
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'
2026-06-10 16:15:55 +02:00
: session . mode === 'codex'
? 'Kill Tmux & Codex'
2026-06-25 00:26:06 +02:00
: session . mode === 'gemini'
? 'Kill Tmux & Gemini'
: 'Kill Tmux & Claude Code' ;
2026-02-26 15:06:36 +01:00
}
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-06-09 10:38:35 -04:00
this . terminalLoadStates . clear ();
2026-06-10 19:56:20 -04:00
this . _xtermSnapshots ? . clear ();
try {
for ( const k of Object . keys ( localStorage )) {
if ( k . startsWith ( 'codeman-xs-' )) localStorage . removeItem ( k );
}
} catch {}
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-07-09 11:26:24 -04:00
// ─── Shortcut Registry ───────────────────────────────────────────────────────
// Returns the merged shortcut list: DEFAULT_SHORTCUTS with any per-shortcut
// overrides from settings.shortcutOverrides applied on top.
getShortcutRegistry () {
const settings = this . loadAppSettingsFromStorage ();
const shortcutOverrides = settings . shortcutOverrides || {};
return DEFAULT_SHORTCUTS . map (( shortcut ) => {
const override = shortcutOverrides [ shortcut . id ];
if ( ! override ) return shortcut ;
2026-07-12 19:06:12 +02:00
// Only binding-shaped fields may come from storage — id/label/group/action
// stay trusted so persisted data can never redirect a shortcut's action or
// spoof another row in the settings/overlay renderers.
const merged = { ... shortcut };
if ( Array . isArray ( override . bindings )) {
merged . bindings = override . bindings ;
delete merged . displayBindings ; // show the override, not the stale default label
}
if ( typeof override . disabled === 'boolean' ) merged . disabled = override . disabled ;
return merged ;
2026-07-09 11:26:24 -04:00
});
}
matchesShortcutEvent ( e , shortcut ) {
2026-07-12 19:06:12 +02:00
if ( ! shortcut || ! Array . isArray ( shortcut . bindings )) return false ;
2026-07-09 11:26:24 -04:00
return shortcut . bindings . some (( binding ) => {
const mods = binding . modifiers || [];
2026-07-12 19:06:12 +02:00
// Ctrl and Cmd are interchangeable as the primary modifier (parity with
// the legacy shortcut table), but every OTHER pressed modifier must be
// declared by the binding — a plain Ctrl+K binding must not also swallow
// Ctrl+Shift+K (the Firefox devtools chord).
const wantsPrimary = mods . includes ( 'ctrl' ) || mods . includes ( 'meta' );
if ( wantsPrimary !== !! ( e . ctrlKey || e . metaKey )) return false ;
if ( mods . includes ( 'shift' ) !== !! e . shiftKey ) return false ;
if ( mods . includes ( 'alt' ) !== !! e . altKey ) return false ;
// Match the physical key when the binding pins one (layout-independent),
// or the produced character otherwise (layout-dependent keys like '+').
if ( binding . code && e . code === binding . code ) return true ;
if ( binding . key && typeof e . key === 'string' && e . key . toLowerCase () === binding . key . toLowerCase ()) return true ;
2026-07-09 11:26:24 -04:00
return false ;
});
}
// ─── Shortcut Overlay Modal ───────────────────────────────────────────────────
// Ctrl/Alt+? opens a floating overlay listing all keyboard shortcuts, grouped
// by category. Uses the merged registry so user overrides are reflected.
showShortcutOverlay () {
const modal = document . getElementById ( 'shortcutOverlayModal' );
if ( ! modal ) return ;
this . renderShortcutOverlay ();
modal . classList . add ( 'active' );
modal . focus ? .();
}
renderShortcutOverlay () {
const list = document . getElementById ( 'shortcutOverlayList' );
if ( ! list ) return ;
const registry = this . getShortcutRegistry ();
const groups = {};
for ( const shortcut of registry ) {
const g = shortcut . group || 'General' ;
if ( ! groups [ g ]) groups [ g ] = [];
groups [ g ]. push ( shortcut );
}
const fmtBindings = ( s ) => {
if ( s . displayBindings ) return s . displayBindings . map (( b ) => `<kbd> ${ escapeHtml ( b ) } </kbd>` ). join ( ' / ' );
if ( ! s . bindings ) return '' ;
return s . bindings . map (( b ) => {
const parts = [...( b . modifiers || []). map (( m ) => m . charAt ( 0 ). toUpperCase () + m . slice ( 1 )), b . key || b . code || '' ];
return `<kbd> ${ escapeHtml ( parts . join ( '+' )) } </kbd>` ;
}). join ( ' / ' );
};
list . innerHTML = Object . entries ( groups ). map (([ group , items ]) =>
`<div class="shortcut-overlay-group"><div class="shortcut-overlay-group-label"> ${ escapeHtml ( group ) } </div>` +
items . map (( s ) => `<div class="shortcut-overlay-row"><span class="shortcut-overlay-label"> ${ escapeHtml ( s . label ) } </span><span class="shortcut-overlay-keys"> ${ fmtBindings ( s ) } </span></div>` ). join ( '' ) +
`</div>`
). join ( '' );
}
closeShortcutOverlay () {
const modal = document . getElementById ( 'shortcutOverlayModal' );
if ( modal ) modal . classList . remove ( 'active' );
}
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 ;