Files
Codeman/src/web/public/app.js
T
Codeman maintainer 82c87f56d1 fix(i18n): zh-CN for the case picker rows, Bottom bar settings, new toasts, tile and spreadsheet text
- Case picker: the "New or link a case…" and "Case settings…" rows that
  replaced the translated + and gear buttons get zh-CN entries reusing the
  buttons' wording, plus the list's "No cases match".
- App Settings, Bottom bar: the whole group translates as one (heading, the
  four Git status rows with #543's max repositories and git timeout, and
  their descriptions), so it never reads half English. The Git panel's two
  button names, which the new "Git status" key reaches, read naturally too.
- Toasts: the three "Could not open a new window for this ..." errors and
  the dictation-closed warning.
- Spreadsheet preview: "Spreadsheet preview failed (<status>)" gets a
  pattern. Worker refusals map their error code to one user sentence each in
  the renderer (the raw message goes to the console), feature ids read as
  words (pivot tables, external links), and the notice bar translates each
  item before the join; a number format's code passes through untouched.
  The worker and core are unchanged, so their pinned strings and the
  asset version hash stay as they were.
- Header Stats Style Tiles: the connection tile's value word reads in
  Chinese through scoped "Connection tile: <word>" keys, never bare-word
  keys ("retry" is also the orchestrator's Retry button, "LIVE" a resume
  list badge). The indicator cache now includes the UI language, so a
  language switch repaints it on the next update.
- Tile grid: the "Loading…" label was CSS content text the translator
  cannot reach; it is now content: attr(data-loading-label), written through
  the translator when the tile is built and each time it starts loading.

Tests pin every new string (zh differs, no English left, English unchanged)
and fail without these changes: the tile harvest sees the loading label and
the rename field, a CSS guard keeps words out of tile generated content, the
Bottom bar group, the case picker rows and toasts read from their source,
the connection tile across a language switch, and the spreadsheet error
codes, notice bar and status line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:23:05 +02:00

10081 lines
477 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Core UI controller for Codeman — tab-based terminal manager with xterm.js.
*
* Defines the CodemanApp class (constructor, init, SSE connection, session lifecycle, tabs,
* navigation). Domain-specific methods are mixed in from separate modules via Object.assign:
*
* 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
*
* ═══ Sections in this file ═══
*
* 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
*
* @class CodemanApp
* @globals {CodemanApp} app - Singleton instance (also on window.app)
*
* @dependency constants.js (SSE_EVENTS, timing constants, escapeHtml, DEC_SYNC_STRIP_RE)
* @dependency mobile-handlers.js (MobileDetection, KeyboardHandler, SwipeHandler)
* @dependency voice-input.js (VoiceInput, DeepgramProvider)
* @dependency notification-manager.js (NotificationManager class)
* @dependency keyboard-accessory.js (KeyboardAccessoryBar, FocusTrap)
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js
* @dependency vendor/xterm-zerolag-input.iife.js (LocalEchoOverlay)
* @loadorder 6 of 15 — loaded after keyboard-accessory.js, before terminal-ui.js
*/
// Codeman App - Tab-based Terminal UI
// Constants, utilities, and escapeHtml() are in constants.js (loaded before this file)
// MobileDetection, KeyboardHandler, SwipeHandler are in mobile-handlers.js
// DeepgramProvider, VoiceInput are in voice-input.js
// ═══════════════════════════════════════════════════════════════
// Global Error & Performance Diagnostics
// ═══════════════════════════════════════════════════════════════
// Writes breadcrumbs to localStorage so they survive tab freezes.
// After a crash, check: localStorage.getItem('codeman-crash-diag')
const _crashDiag = {
_entries: [],
_maxEntries: 50,
// 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),
log(msg) {
// Entries are joined with '\n' into ONE localStorage value and beaconed to
// the server, and some call sites interpolate text this client does not
// control (a WebSocket close `reason` arrives from the server). A newline
// in there forges extra entries in the trail; an unbounded string can fill
// the storage quota and silently kill every later breadcrumb. Flatten and
// cap. CodemanDiag is loaded before app.js, but guard anyway — a
// diagnostic that can throw is worse than no diagnostic.
// Bound to a local FIRST: `CodemanDiag?.x` still throws a ReferenceError
// when the identifier was never declared, and this is the one function in
// the app that must never throw.
const diag = typeof CodemanDiag !== 'undefined' ? CodemanDiag : null;
const flat = diag?.sanitizeDiagEntry
? diag.sanitizeDiagEntry(msg)
: String(msg == null ? '' : msg)
.replace(/[\r\n\u2028\u2029]+/g, ' ')
.slice(0, diag?.DIAG_ENTRY_MAX_CHARS ?? 300);
const entry = `${new Date().toISOString().slice(11,23)} ${flat}`;
this._entries.push(entry);
if (this._entries.length > this._maxEntries) this._entries.shift();
try { localStorage.setItem('codeman-crash-diag', this._entries.join('\n')); } catch {}
}
};
// Log previous crash breadcrumbs on startup, 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.
try {
const prev = localStorage.getItem('codeman-crash-diag');
if (prev) {
console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
navigator.sendBeacon(CodemanBase.url('/api/crash-diag'), JSON.stringify({ data: prev, id: _crashDiag._pageId + '-prev' }));
}
} catch {}
_crashDiag.log('PAGE LOAD');
// Heartbeat: send breadcrumbs to server every 2s so they survive tab freezes.
function _crashDiagBeacon() {
try {
if (_crashDiag._entries.length > 0) {
navigator.sendBeacon(CodemanBase.url('/api/crash-diag'), JSON.stringify({ data: _crashDiag._entries.join('\n'), id: _crashDiag._pageId }));
}
} catch {}
}
setInterval(() => {
try { localStorage.setItem('codeman-crash-heartbeat', String(Date.now())); } catch {}
_crashDiagBeacon();
}, 2000);
// 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();
});
window.addEventListener('error', (e) => {
_crashDiag.log(`ERROR: ${e.message} at ${e.filename}:${e.lineno}`);
console.error('[CRASH-DIAG] Uncaught error:', e.message, '\n File:', e.filename, ':', e.lineno, ':', e.colno, '\n Stack:', e.error?.stack);
});
window.addEventListener('unhandledrejection', (e) => {
_crashDiag.log(`UNHANDLED: ${e.reason?.message || e.reason}`);
console.error('[CRASH-DIAG] Unhandled promise rejection:', e.reason?.message || e.reason, '\n Stack:', e.reason?.stack);
});
// Detect long tasks (>50ms main thread blocks) — these cause "page unresponsive"
if (typeof PerformanceObserver !== 'undefined') {
try {
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 200) {
_crashDiag.log(`LONG_TASK: ${entry.duration.toFixed(0)}ms`);
console.warn(`[CRASH-DIAG] Long task: ${entry.duration.toFixed(0)}ms (type: ${entry.entryType}, name: ${entry.name})`);
}
}
});
longTaskObserver.observe({ type: 'longtask', buffered: true });
} catch { /* longtask not supported */ }
}
// Track WebGL context loss/restore events on all canvases
const _origGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function(type, ...args) {
const ctx = _origGetContext.call(this, type, ...args);
if (type === 'webgl2' || type === 'webgl') {
this.addEventListener('webglcontextlost', (e) => {
_crashDiag.log(`WEBGL_LOST: ${this.width}x${this.height}`);
console.error('[CRASH-DIAG] WebGL context LOST on canvas', this.width, 'x', this.height, '— prevented:', e.defaultPrevented);
});
this.addEventListener('webglcontextrestored', () => {
_crashDiag.log('WEBGL_RESTORED');
console.warn('[CRASH-DIAG] WebGL context restored');
});
}
return ctx;
};
// ═══════════════════════════════════════════════════════════════
// SSE Handler Map — event-to-method routing table
// ═══════════════════════════════════════════════════════════════
// connectSSE() iterates this array to register all listeners in a single loop.
// Omitted no-op events (registered by server but unused in UI):
// respawn:stepSent, respawn:aiCheckStarted, respawn:aiCheckCompleted,
// respawn:aiCheckFailed, respawn:aiCheckCooldown
const _SSE_HANDLER_MAP = [
// Core
[SSE_EVENTS.INIT, '_onInit'],
// Session lifecycle
[SSE_EVENTS.SESSION_CREATED, '_onSessionCreated'],
[SSE_EVENTS.SESSION_UPDATED, '_onSessionUpdated'],
[SSE_EVENTS.SESSION_DELETED, '_onSessionDeleted'],
[SSE_EVENTS.SESSION_TERMINAL, '_onSSETerminal'],
[SSE_EVENTS.SESSION_NEEDS_REFRESH, '_onSSENeedsRefresh'],
[SSE_EVENTS.SESSION_CLEAR_TERMINAL, '_onSSEClearTerminal'],
[SSE_EVENTS.SESSION_COMPLETION, '_onSessionCompletion'],
[SSE_EVENTS.SESSION_ERROR, '_onSessionError'],
[SSE_EVENTS.SESSION_EXIT, '_onSessionExit'],
[SSE_EVENTS.SESSION_IDLE, '_onSessionIdle'],
[SSE_EVENTS.SESSION_WORKING, '_onSessionWorking'],
[SSE_EVENTS.SESSION_AUTO_CLEAR, '_onSessionAutoClear'],
[SSE_EVENTS.SESSION_LIMIT_PAUSE_SCHEDULED, '_onSessionLimitPauseScheduled'],
[SSE_EVENTS.SESSION_LIMIT_RESUME, '_onSessionLimitResume'],
[SSE_EVENTS.SESSION_LIMIT_RESUME_CANCELLED, '_onSessionLimitResumeCancelled'],
[SSE_EVENTS.SESSION_RESPAWN_BREAKER_TRIPPED, '_onSessionRespawnBreakerTripped'],
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
[SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'],
// Scheduled runs
[SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'],
[SSE_EVENTS.SCHEDULED_UPDATED, '_onScheduledUpdated'],
[SSE_EVENTS.SCHEDULED_COMPLETED, '_onScheduledCompleted'],
[SSE_EVENTS.SCHEDULED_STOPPED, '_onScheduledStopped'],
// Scheduled jobs (cron-style scheduler)
[SSE_EVENTS.CRON_JOBS_CHANGED, '_onCronJobsChanged'],
[SSE_EVENTS.CRON_JOB_DELETED, '_onCronJobsChanged'],
[SSE_EVENTS.CRON_RUN_CREATED, '_onCronRunChanged'],
[SSE_EVENTS.CRON_RUN_UPDATED, '_onCronRunChanged'],
// 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'],
// Remote auto-reconnect (COD-108)
[SSE_EVENTS.REMOTE_SESSION_RECONNECTED, '_onRemoteSessionReconnected'],
[SSE_EVENTS.REMOTE_RECONNECT_EXHAUSTED, '_onRemoteReconnectExhausted'],
[SSE_EVENTS.REMOTE_HOST_WAKING, '_onRemoteHostWaking'],
[SSE_EVENTS.REMOTE_HOST_WAKE_FAILED, '_onRemoteHostWakeFailed'],
// 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_ELICITATION_COMPLETE, '_onHookElicitationComplete'],
[SSE_EVENTS.HOOK_ELICITATION_RESPONSE, '_onHookElicitationResponse'],
[SSE_EVENTS.HOOK_STOP, '_onHookStop'],
[SSE_EVENTS.HOOK_AGENT_WORKING, '_onHookAgentWorking'],
[SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'],
[SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'],
// Approvals Inbox (handlers in approvals-ui.js)
[SSE_EVENTS.APPROVAL_PENDING, '_onApprovalPending'],
[SSE_EVENTS.APPROVAL_UPDATED, '_onApprovalUpdated'],
[SSE_EVENTS.APPROVAL_RESOLVED, '_onApprovalResolved'],
// 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'],
// Workflow runs (ultracode)
[SSE_EVENTS.WORKFLOW_RUN_DISCOVERED, '_onWorkflowRunDiscovered'],
[SSE_EVENTS.WORKFLOW_RUN_UPDATED, '_onWorkflowRunUpdated'],
[SSE_EVENTS.WORKFLOW_RUN_REMOVED, '_onWorkflowRunRemoved'],
// Images
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
[SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'],
// 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'],
// Orchestrator loop
[SSE_EVENTS.ORCHESTRATOR_STATE_CHANGED, '_onOrchestratorStateChanged'],
[SSE_EVENTS.ORCHESTRATOR_PLAN_PROGRESS, '_onOrchestratorPlanProgress'],
[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'],
// Clipboard
[SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'],
// Session order (global tab order sync, COD-131)
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
// Owner tab layout (grouped vertical rail)
[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged'],
// Web tabs (dashboard URLs)
[SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'],
];
// ═══════════════════════════════════════════════════════════════
// 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;
}
// ═══════════════════════════════════════════════════════════════
// Exited-agent tab label (Ark0N/Codeman#446)
// ═══════════════════════════════════════════════════════════════
// The server publishes session.paneExit when the agent inside a local tmux
// pane has exited while remain-on-exit kept the pane. The field is tri-state
// and its third state is absence, which means Codeman does not know — that
// renders as nothing here and must never read as alive.
//
// status and signal are each optional, because tmux can know the pane died
// without reporting how (a SIGKILLed pane on tmux 3.2a reports neither). So an
// absent status shows a bare "exited" rather than "exited (0)": a clean exit
// and an unexplained one must not look the same.
function paneExitLabel(paneExit) {
if (!paneExit || typeof paneExit !== 'object') return '';
if (typeof paneExit.signal === 'number' && paneExit.signal > 0) return `exited (signal ${paneExit.signal})`;
if (typeof paneExit.status === 'number') return `exited (${paneExit.status})`;
return 'exited';
}
// The tab's accessible name, with the exit appended when there is one. Shared
// by the full render and applyPaneExitBadge() so the two cannot disagree.
function paneExitAriaLabel(name, label) {
return label ? `${name} session, agent ${label}` : `${name} session`;
}
// Add, update or remove one tab's exited-agent badge in place. Separate from
// the render loop so it can be exercised directly: this is the only path a
// session going live-to-exited ever takes, since that transition adds and
// removes no tab and so never reaches the full rebuild.
function applyPaneExitBadge(tab, paneExit) {
const label = paneExitLabel(paneExit);
const existing = tab.querySelector('.tab-exited-badge');
// Quiets the status dot too. That dot reports `status`, which stays `idle` or
// `busy` for an exited pane by design, so without this a green or pulsing dot
// sits next to a badge saying the agent is gone.
tab.classList.toggle('tab-agent-exited', !!label);
// The tab's aria-label overrides its contents for the accessible name, and the
// badge is aria-hidden like its siblings, so the exit has to ride the label.
// Compared with the last English label set (data-aria-source, seeded by the
// full render too), never the attribute: in zh-CN the translator rewrites it,
// and writing English back on every pass would have it translate again.
const name = tab.querySelector('.tab-name')?.dataset?.fullName;
if (name) {
const aria = paneExitAriaLabel(name, label);
if (tab.dataset.ariaSource !== aria) {
tab.dataset.ariaSource = aria;
tab.setAttribute('aria-label', aria);
}
}
if (!label) {
existing?.remove();
return;
}
if (!existing) {
const badge = document.createElement('span');
badge.className = 'tab-exited-badge';
badge.setAttribute('aria-hidden', 'true');
// Translated like any other text (i18n.js has "exited" and its exit-code
// forms). The comparison below is with the last English label (data-label),
// never the DOM, which holds the translation in zh-CN: a DOM compare would
// write the English back on every pass for the translator to redo.
badge.dataset.label = label;
badge.textContent = label;
tab.querySelector('.tab-name')?.insertAdjacentElement('afterend', badge);
return;
}
if (existing.dataset.label !== label) {
existing.dataset.label = label;
existing.textContent = label;
}
}
const DEFAULT_SHORTCUTS = [
{
id: 'show-shortcuts',
group: 'Panels',
label: 'Show Shortcuts',
bindings: [
{ modifiers: ['ctrl'], key: '?', code: 'Slash' },
{ modifiers: ['ctrl', 'shift'], key: '?' },
{ modifiers: ['alt'], key: '?', code: 'Slash' },
],
action: 'showShortcutOverlay',
},
{
id: 'close-session',
group: 'Session',
label: 'Close Session',
// ⚠️ No default key. This used to be Ctrl+W, which is "delete the previous
// word" in every shell, readline prompt and agent CLI, so muscle memory
// killed the session (tmux and the CLI, with no confirm) mid-sentence, and
// with the split open it was not even the pane being typed in. Ctrl+W now
// reaches the terminal like any other key. Closing stays on the tab's close
// control and menu (with their confirm), and anyone who wants a key binds
// one in App Settings → Shortcuts.
bindings: [],
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: 'copy-selection',
group: 'Terminal',
label: 'Copy Selection',
// Bindings match on `key`, not `code`: xterm decides which byte to emit from the
// PRODUCED character, so intercepting a physical KeyC that doesn't produce "c"
// would diverge from the chord that actually sends ^C.
bindings: [
{ modifiers: ['ctrl'], key: 'c' },
{ modifiers: ['ctrl', 'shift'], key: 'C' },
],
// Dispatched by shouldCopyTerminalSelectionFromShortcut() in terminal-ui.js and
// deliberately absent from SHORTCUT_ACTIONS: the generic capture loop always
// preventDefaults on a match, which would cost the user the interrupt key.
action: 'copyTerminalSelection',
},
{
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',
},
{
id: 'restore-terminal-size',
group: 'Terminal',
label: 'Restore Terminal Size',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'R' }],
action: 'restoreTerminalSize',
},
{
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: 'toggle-session-sidebar',
group: 'Session',
label: 'Toggle Session Sidebar',
// Alt+B, not Ctrl+B: Ctrl+B must reach the terminal (tmux prefix,
// readline backward-char). The Alt block below claims only Digit1-9 and
// the brackets, and the registry claims Alt for KeyK and Slash only.
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
action: 'toggleSessionSidebar',
},
// Tile grid (tile-grid.js). Dispatched by tileShortcutFor()/runTileShortcut()
// and deliberately absent from SHORTCUT_ACTIONS: each applies only in some
// states (the focus chords only while the grid is open), and outside them the
// chord must reach the terminal untouched. Every xterm key handler swallows a
// chord that applies, so it never reaches a PTY. Defaults: Ctrl+Shift+G makes
// xterm emit nothing (a shifted Ctrl letter) and overrides only the browser's
// find-previous; Alt+Shift+Arrows are bound by no CLI Codeman runs. The
// arrow chords (focus, move) never apply in a text field, where shifted
// arrows select (tileShortcutFor).
{
id: 'toggle-tile-grid',
group: 'Tiles',
label: 'Toggle Tile Grid',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'G', code: 'KeyG' }],
action: 'toggleTileGrid',
},
{
id: 'focus-tile-left',
group: 'Tiles',
label: 'Focus Tile Left',
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowLeft' }],
action: 'focusTileLeft',
},
{
id: 'focus-tile-right',
group: 'Tiles',
label: 'Focus Tile Right',
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowRight' }],
action: 'focusTileRight',
},
{
id: 'focus-tile-up',
group: 'Tiles',
label: 'Focus Tile Up',
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowUp' }],
action: 'focusTileUp',
},
{
id: 'focus-tile-down',
group: 'Tiles',
label: 'Focus Tile Down',
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowDown' }],
action: 'focusTileDown',
},
// Move the focused tile: it trades places with the neighbour the focus
// chords would pick. Ctrl+Shift+Arrows because every other two-modifier
// arrow chord is taken: Ctrl+Alt+Arrows switch workspaces (GNOME, Xfce, and
// rotate the screen on some Windows graphics drivers), Ctrl+Alt+Shift+Arrows
// move a window to another workspace (GNOME, Cinnamon, Xfce), Super chords
// belong to the desktop, Alt+Arrows are the browser's back and forward, and
// Alt+Shift+Arrows focus tiles. No browser, GNOME, KDE or macOS default and
// no Claude Code default uses Ctrl+Shift+Arrows (it parallels Ctrl+Shift+{ }
// for moving tabs); what it costs is word selection, so the chords skip a
// text field (tileShortcutFor) and give up only a terminal editor's
// word selection (nano, micro, emacs) inside a tile while the grid is open.
{
id: 'move-tile-left',
group: 'Tiles',
label: 'Move Tile Left',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowLeft' }],
action: 'moveTileLeft',
},
{
id: 'move-tile-right',
group: 'Tiles',
label: 'Move Tile Right',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowRight' }],
action: 'moveTileRight',
},
{
id: 'move-tile-up',
group: 'Tiles',
label: 'Move Tile Up',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowUp' }],
action: 'moveTileUp',
},
{
id: 'move-tile-down',
group: 'Tiles',
label: 'Move Tile Down',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowDown' }],
action: 'moveTileDown',
},
{
id: 'zoom-tile',
group: 'Tiles',
label: 'Zoom Focused Tile',
// Toggles. Alt+Shift+Enter would otherwise reach the CLI as ESC CR (Alt+Enter
// is a newline in Claude Code); it is swallowed only while the grid is open.
bindings: [{ modifiers: ['alt', 'shift'], key: 'Enter' }],
action: 'zoomTile',
},
{
id: 'remove-tile',
group: 'Tiles',
label: 'Remove Focused Tile',
// Unbound by default; the session keeps running either way.
bindings: [],
action: 'removeTile',
},
{
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'],
},
];
// ═══════════════════════════════════════════════════════════════
// CodemanApp Class — constructor and global state
// ═══════════════════════════════════════════════════════════════
/**
* How often the rich sidebar rewrites its relative stamps in place. Matches the
* two home screens (mobile-overview.js, home-sessions.js). Deliberately a local
* const and not a constants.js export: an undefined interval would make
* setInterval fire on every frame, and constants.js is cached independently.
*/
const SIDEBAR_RICH_CLOCK_MS = 20000;
/**
* How long a `#session=<id>` link waits for the session list to name its id
* before the dashboard drops it with a "Session not found" toast (see
* _armUrlSessionWait). Long enough for a page that has just created the
* session to see its session:created land here.
*/
const URL_SESSION_WAIT_MS = 30000;
/**
* How old the sessionStorage copy of unsaved tab-group edits may be when the
* next page replays it (see _restorePendingTabLayoutEdits). A reload takes
* seconds; an older copy is from a tab that sat closed or a different visit.
*/
const TAB_LAYOUT_PENDING_MAX_AGE_MS = 60000;
class CodemanApp {
constructor() {
this.sessions = new Map();
this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
this.draggedTabId = null; // Currently dragged tab session ID
// Owner tab layout: read via GET /api/tab-layout and edited from the vertical
// rail via PUT /api/tab-layout (editTabLayout). It decides how the rail GROUPS
// rows; the server projects it onto the session order, so sessionOrder above
// stays the tab order.
this.tabLayout = null;
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
this._lastTabGroupStructureKey = null;
this.cases = [];
this.currentRun = null;
this.totalTokens = 0;
this.globalStats = null; // Global token/cost stats across all sessions
this.eventSource = null;
// 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);
// 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;
this.terminal = null;
this.fitAddon = null;
this.activeSessionId = null;
// ── 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;
// A session another page asked for with a `#session=<id>` link. It waits
// here until the session list has that id (see _selectUrlSession).
this._urlSessionId = this.isSoloWindow ? null : this._takeUrlSession();
this._urlSessionWaitTimer = null; // bounds that wait (_armUrlSessionWait)
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
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
this._detachOrphanStrikes = new Map(); // id -> consecutive unanswered roll-calls (redock at 2)
this._initGeneration = 0; // dedup concurrent handleInit calls
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
this._selectGeneration = 0; // cancel stale selectSession loads
// Non-shell sessions whose full tmux scrollback has already been replayed this
// page load (COD-47). Shells deliberately start from a bounded tail because
// their scrollback can be very large; full history stays available on demand.
// Tracked PER SESSION rather than as a single "first load" flag (issue #205).
this._fullHistoryLoaded = new Set();
// Cooldown per session for the scroll-to-top "load more history" re-pull.
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
this._fullHistoryRepullInFlight = false;
// Sessions whose last re-pull came back THINNER than the live buffer (a
// repaint-mode CLI pane, where tmux keeps no history of its own). The pull is
// refused for those and retried far more slowly — see _maybeRefetchFullHistory.
this._fullHistoryRepullUseless = new Set();
// Sessions where the geometry replay has already been tried and did NOT
// converge, so the pane is one this browser cannot size. Mirrors the Set
// above: `resizeRetry` caps the recursion inside one select, and this is
// what stops a fresh select from paying for the same answer again — see
// the geometry gate in selectSession.
this._geometryRetryUseless = new Set();
this.terminalLoadStates = new Map(); // Map<sessionId, { generation, phase }>
this.respawnStatus = {};
this.respawnTimers = {}; // Track timed respawn timers
this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } }
this.respawnActionLogs = {}; // { sessionId: [action, action, ...] } (max 20)
this.timerCountdownInterval = null; // Interval for updating countdown display
this.terminalBuffers = new Map(); // Store terminal content per session
this.editingSessionId = null; // Session being edited in options modal
this.pendingCloseSessionId = null; // Session pending close confirmation
this.muxSessions = []; // Screen sessions for process monitor
// Ralph loop/todo state per session
this.ralphStates = new Map(); // Map<sessionId, { loop, todos }>
// Subagent (Claude Code background agent) tracking
this.subagents = new Map(); // Map<agentId, SubagentInfo>
this.subagentActivity = new Map(); // Map<agentId, activity[]> - recent tool calls/progress
this.subagentToolResults = new Map(); // Map<agentId, Map<toolUseId, result>> - tool results by toolUseId
this.activeSubagentId = null; // Currently selected subagent for detail view
this.subagentPanelVisible = false;
// 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;
// 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;
this.subagentWindows = new Map(); // Map<agentId, { element, position }>
this.subagentWindowZIndex = ZINDEX_SUBAGENT_BASE;
this.minimizedSubagents = new Map(); // Map<sessionId, Set<agentId>> - minimized to tab
this._subagentHideTimeout = null; // Timeout for hover-based dropdown hide
// PERSISTENT parent associations - agentId -> sessionId
// This is the SINGLE SOURCE OF TRUTH for which tab an agent window connects to.
// Once set, never recalculated. Persisted to localStorage and server.
this.subagentParentMap = new Map();
// Agent Teams tracking
this.teams = new Map(); // Map<teamName, TeamConfig>
this.teamTasks = new Map(); // Map<teamName, TeamTask[]>
this.teammateMap = new Map(); // Map<agentId-prefix, {name, color, teamName}> for quick lookup
// Teammate tmux pane terminals (Agent Teams feature)
this.teammatePanesByName = new Map(); // Map<name, { paneTarget, sessionId, color }>
this.teammateTerminals = new Map(); // Map<agentId, { terminal, fitAddon, paneTarget, sessionId, resizeObserver }>
this.terminalBufferCache = new Map(); // Map<sessionId, string> — client-side cache for instant tab re-visits (max 20)
this.ralphStatePanelCollapsed = true; // Default to collapsed
this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel
// Plan subagent windows (visible agents during plan generation)
this.planSubagents = new Map(); // Map<agentId, { type, model, status, startTime, element, relativePos }>
this.planSubagentWindowZIndex = ZINDEX_PLAN_SUBAGENT_BASE;
this.planGenerationStopped = false; // Flag to ignore SSE events after Stop
this.planAgentsMinimized = false; // Whether agent windows are minimized to tab
// Wizard dragging state
this.wizardDragState = null; // { startX, startY, startLeft, startTop, isDragging }
this.wizardDragListeners = null; // { move, up } for cleanup
this.wizardPosition = null; // { left, top } - null means centered
// Project Insights tracking (active Bash tools with clickable file paths)
this.projectInsights = new Map(); // Map<sessionId, ActiveBashTool[]>
this.logViewerWindows = new Map(); // Map<windowId, { element, eventSource, filePath }>
this.logViewerWindowZIndex = ZINDEX_LOG_VIEWER_BASE;
this.projectInsightsPanelVisible = false;
// Orchestrator loop state
this.orchestratorState = null; // { state, plan, currentPhaseIndex, stats }
this.orchestratorPanelVisible = false;
this.currentSessionWorkingDir = null; // Track current session's working dir for path normalization
// Image popup windows (auto-open for detected screenshots/images)
this.imagePopups = new Map(); // Map<imageId, { element, sessionId, filePath }>
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
this.attachmentCards = new Map(); // Map<attachmentId, { element, sessionId, filePath }>
this.attachmentCardStack = null;
this.attachmentHistoryCounts = new Map(); // Map<sessionId, count>
this.attachmentHistoryItems = [];
this.attachmentHistoryDrawerOpen = false;
// File browser state (methods in panels-ui.js)
this.fileBrowserData = null;
this.fileBrowserExpandedDirs = new Set();
this.fileBrowserFilter = '';
this.fileBrowserAllExpanded = false;
this.fileBrowserDragListeners = null;
// Show hidden (dot-prefixed) files and folders in the File Viewer tree.
// Per-device, persisted to its own localStorage key by panels-ui.js. Safe to
// call a mixin method here: instantiation is deferred to DOMContentLoaded,
// so every module's Object.assign has already run.
this.fileBrowserShowHidden = this._loadFileBrowserShowHidden?.() ?? false;
this.filePreviewContent = '';
// Toast container cache (methods in panels-ui.js)
this._toastContainer = null;
// Tunnel indicator state
this._tunnelUrl = null;
// Tab alert states: Map<sessionId, 'action' | 'idle'>
this.tabAlerts = new Map();
// Pending hooks per session: Map<sessionId, Set<hookType>>
// Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt)
this.pendingHooks = new Map();
// Sessions THIS tab is closing right now. closeSession() owns the follow-up
// selection, so _onSessionDeleted must not race its own delete's SSE
// broadcast to the welcome screen. Set<sessionId>, cleared in a finally.
this._closingSessions = new Set();
// Approvals Inbox: Map<approvalId, ApprovalItem> (methods in approvals-ui.js)
this.approvals = new Map();
// 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
this._wsState = 'disconnected'; // connecting | connected | reconnecting | fallback | disconnected
this._wsLastRecvAt = 0; // ms timestamp of the last frame received on the active WS
// Session whose socket dropped unintentionally, so output produced during
// the outage is missing from its buffer. Output frames carry no sequence
// number, so the only recovery is to refetch on the next successful open.
this._wsOutputGapSession = null;
// Terminal write batching with DEC 2026 sync support
this.pendingWrites = [];
this.writeFrameScheduled = false;
// xterm.write() parses asynchronously. Keep at most one live-output chunk
// inside xterm so its private WriteBuffer cannot bypass our 128KB cap.
this._terminalWriteInFlight = false;
this._terminalWriteInFlightBytes = 0;
this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll
this.syncWaitTimeout = null; // Timeout for incomplete sync blocks
this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes
this._loadBufferQueue = null; // queued SSE events during buffer load
this._bufferLoadSeq = 0;
this._bufferLoadOwner = null;
// Single-flight token for terminal buffer recovery. The identity check also
// lets a session switch invalidate an older fetch without blocking the new tab.
this._terminalRefreshOwner = null;
// Flicker filter state (buffers output after screen clears)
this.flickerFilterBuffer = '';
this.flickerFilterActive = false;
this.flickerFilterTimeout = null;
// Render debounce timers (managed by _debouncedCall)
this._debounceTimers = Object.create(null);
// System stats polling
this.systemStatsInterval = null;
// SSE reconnect timeout (to prevent orphaned timeouts)
this.sseReconnectTimeout = null;
// SSE event listener cleanup function (to prevent listener accumulation on reconnect)
this._sseListenerCleanup = null;
// SSE connection status tracking
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.isOnline = navigator.onLine;
// SSE staleness watchdog. An EventSource that stops delivering does not
// always error (a proxy that idle-closed it, a resumed laptop), so
// `onerror` never fires and every SSE-driven surface freezes silently.
// The server heartbeats every 15s; going quiet for three of them means the
// stream is a zombie and has to be rebuilt. The decision is pure
// (computeSseStale in constants.js); these are its inputs. The threshold
// is an instance field so a browser test can shrink it.
this._sseLastMessageAt = 0;
this._sseStaleTimeoutMs = window.CodemanSseStale?.TIMEOUT_MS ?? 45000;
this._sseStaleWatchdog = null;
// Connection-loss UI (banner + full-screen overlay). The decision itself is
// pure and lives in constants.js (computeConnectionLossUi); these are just
// its inputs. `_connDownSince` is the timestamp the transport LEFT the
// connected state, which is what the grace window is measured from.
this._connDownSince = null;
this._nextSseRetryAt = null; // when the scheduled SSE retry fires (countdown)
this._offlineOverlayDismissed = false;
this._offlineRetryPending = false; // a user-triggered retry is in flight
this._offlineUiTicker = null;
this._lastOfflineUiKey = '';
// 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.
this._connectionStatus = 'connected';
this._clientId = '';
this._seqCounters = new Map(); // sessionId -> last issued seq
this._pendingDeliveries = new Map(); // sessionId -> [{seq,data,useMux,ts,tries,sentAt}]
// Last rendered connection-indicator tuple; the hot input path skips DOM
// writes when the freshly computed descriptor is identical (COD-136).
this._lastIndicatorDescriptor = null;
this._lastIndicatorLanguage = null; // the UI language it was rendered in
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
// Terminal sockets OTHER than the primary one (`this._ws`), keyed by the
// session they are bound to: the split pane's second terminal registers its
// socket here so its input rides the same exactly-once queue. Values are
// `{ ws, lastRecvAt }` handles owned by that terminal (see _inputSocketFor).
this._extraInputSockets = new Map();
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());
// Tab group edits not yet confirmed by the server survive a reload.
window.addEventListener('pagehide', () => this._persistPendingTabLayoutEdits());
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') this._persistReliableNow();
// A background tab's timers are throttled, so the 5s watchdog may not
// have run for minutes, and a wake/unlock is exactly when a stream
// comes back zombie. Checking here is what makes recovery feel instant
// instead of up to a full timeout late.
else this._checkSseStale();
});
// 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
// Predictive write-through echo (codex) — created after terminal.open()
// from the separate vendor/xterm-predictive-echo.js bundle (may stay null)
this._predictiveEcho = null;
this._localEchoPolicy = 'off'; // 'buffer' | 'predict' | 'off' (per active session)
this._restoringFlushedState = false; // true during selectSession buffer load — protects flushed Maps
// Accessibility: Focus trap for modals
this.activeFocusTrap = null;
// Notification system
this.notificationManager = new NotificationManager(this);
this.idleTimers = new Map(); // Map<sessionId, timeout> for stuck detection
// DOM element cache for performance (avoid repeated getElementById calls)
this._elemCache = {};
this.init();
}
// Cached element getter - avoids repeated DOM queries
$(id) {
if (!this._elemCache[id]) {
this._elemCache[id] = document.getElementById(id);
}
return this._elemCache[id];
}
// Clear a named timeout property: if (this[name]) { clearTimeout(this[name]); this[name] = null; }
_clearTimer(timerName) {
if (this[timerName]) {
clearTimeout(this[timerName]);
this[timerName] = null;
}
}
// Check if a selectSession generation is stale (a newer tab switch has started).
// If stale, cleans up buffer-loading state and returns true.
_isStaleSelect(selectGen) {
if (selectGen !== this._selectGeneration) {
if (this._isLoadingBuffer) this._finishBufferLoad(selectGen);
this._restoringFlushedState = false;
return true;
}
return false;
}
// Format token count: 1000k -> 1m, 1450k -> 1.45m, 500 -> 500
formatTokens(count) {
if (count >= 1000000) {
const m = count / 1000000;
return m >= 10 ? `${m.toFixed(1)}m` : `${m.toFixed(2)}m`;
} else if (count >= 1000) {
const k = count / 1000;
return k >= 100 ? `${k.toFixed(0)}k` : `${k.toFixed(1)}k`;
}
return String(count);
}
// Estimate cost from tokens using Claude Opus pricing
// Input: $15/M tokens, Output: $75/M tokens
estimateCost(inputTokens, outputTokens) {
const inputCost = (inputTokens / 1000000) * 15;
const outputCost = (outputTokens / 1000000) * 75;
return inputCost + outputCost;
}
// ═══════════════════════════════════════════════════════════════
// Pending Hooks State Machine
// ═══════════════════════════════════════════════════════════════
// Track pending hook events per session to determine tab alerts.
// Action hooks (permission_prompt, elicitation_dialog) take priority over idle_prompt.
setPendingHook(sessionId, hookType) {
if (!this.pendingHooks.has(sessionId)) {
this.pendingHooks.set(sessionId, new Set());
}
this.pendingHooks.get(sessionId).add(hookType);
this.updateTabAlertFromHooks(sessionId);
}
clearPendingHooks(sessionId, hookType = null) {
const hooks = this.pendingHooks.get(sessionId);
if (!hooks) return;
if (hookType) {
hooks.delete(hookType);
} else {
hooks.clear();
}
if (hooks.size === 0) {
this.pendingHooks.delete(sessionId);
}
this.updateTabAlertFromHooks(sessionId);
}
/**
* "I looked at this session": spend its pending IDLE tab alert (the yellow
* one), locally AND server-side. The clear used to live only in this tab's
* memory, so `seedApprovals()` re-armed it from `GET /api/approvals` on the
* next reload (a tab you had already checked went yellow again), and the
* user's other devices never heard about it. The server marks the approval
* item acknowledged (it stays pending and answerable) and broadcasts
* `approval:updated`, which is what clears the alert everywhere else.
*
* ⚠️ Idle only: action alerts (permission/question) mean an unanswered dialog
* is on screen, and looking at one does not answer it.
*/
markIdleAlertSeen(sessionId) {
// `pendingHooks?` because _ackDelivery calls this from the input hot path,
// which partial app instances (the vm-loaded delivery tests) also drive.
if (!this.pendingHooks?.get(sessionId)?.has('idle_prompt')) return;
this.clearPendingHooks(sessionId, 'idle_prompt');
this.acknowledgeIdleApprovalOnView?.(sessionId);
}
updateTabAlertFromHooks(sessionId) {
const hooks = this.pendingHooks.get(sessionId);
if (!hooks || hooks.size === 0) {
this.tabAlerts.delete(sessionId);
} else if (hooks.has('permission_prompt') || hooks.has('elicitation_dialog')) {
this.tabAlerts.set(sessionId, 'action');
} else if (hooks.has('idle_prompt')) {
this.tabAlerts.set(sessionId, 'idle');
}
this.renderSessionTabs();
}
// ═══════════════════════════════════════════════════════════════
// Init — app bootstrap and mobile setup
// ═══════════════════════════════════════════════════════════════
init() {
// Initialize mobile detection first (adds device classes to body)
MobileDetection.init();
// 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');
// A page holding this window switches its tab by changing only the
// fragment, which keeps the page loaded (see sessionIdFromFragment).
if (!this.isSoloWindow) {
window.addEventListener('hashchange', () => {
const id = this._takeUrlSession();
if (!id) return;
// A new link replaces one still waiting, and gets a wait of its own.
this._retireUrlSession();
this._urlSessionId = id;
this._selectUrlSession();
});
}
// mobile.css keeps the pop-out icon off phones unless a host can open windows.
document.documentElement.classList.toggle('host-windows', this.hasHostWindows());
// Initialize mobile handlers
KeyboardHandler.init();
SwipeHandler.init();
VoiceInput.init();
KeyboardAccessoryBar.init();
// Apply keyboard bar mode from settings. Always set it (not only when the
// extended bar is on) so the bar's remembered agent-session layout matches
// the setting before the first shell session swaps in the terminal bar.
const _kbSettings = this.loadAppSettingsFromStorage();
KeyboardAccessoryBar.setMode(_kbSettings.extendedKeyboardBar ? 'extended' : 'simple');
this.applyHeaderVisibilitySettings();
this.restorePlanUsageChip();
this.applySkin();
this.applyLocalization();
// Calls applyTabWrapSettings() itself (it owns tabs-two-rows / tabs-show-folder)
// and then applies the sidebar variant on top — do not call both.
this.applySessionListLayout();
this.applyTabOrientation();
this.initTabRailResize?.();
this.applyMonitorVisibility();
this.applyLineageLineSettings?.();
this._installLineageStripScrollListener?.();
this._setupTabMiddleClickClose();
// Must run before the first session:created can arrive: markSessionTabEntering()
// ignores ids until this sets up its state, which is what keeps the tabs
// restored on page load from animating.
this.initEntranceAnimations?.();
// Remove mobile-init class now that JS has applied visibility settings.
// The inline <script> in <head> added this to prevent flash-of-content on mobile.
document.documentElement.classList.remove('mobile-init');
// Defer heavy terminal canvas creation to next frame — lets browser paint header/skeleton first.
// IMPORTANT: connectSSE must run AFTER initTerminal to prevent a race where SSE data
// arrives before the terminal exists, orphaning data in pendingWrites and corrupting
// escape sequence boundaries when later concatenated with fresh data.
requestAnimationFrame(() => {
this.initTerminal();
this.loadFontSize();
this.connectSSE();
// Only fetch state if SSE init event hasn't arrived within 3s (avoids duplicate handleInit)
this._initFallbackTimer = setTimeout(() => {
if (this._initGeneration === 0) this.loadState();
}, 3000);
});
// Register service worker for push notifications
this.registerServiceWorker();
// Fetch tunnel status for header indicator (desktop only)
this.loadTunnelStatus();
// Ask whether a host reboot left sessions worth rebuilding (banner, never
// automatic). handleInit() re-reads it on every SSE init; this covers the
// path where that event never arrives.
this.initRebootRestoreBanner?.();
// Share a single settings fetch between both consumers
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null).catch(() => null);
this.loadQuickStartCases(null, settingsPromise);
this._initRunMode();
this.initWebviews?.();
this.setupEventListeners();
// Mobile: ensure button taps register even when keyboard is visible.
// On mobile, tapping a button while the soft keyboard is up causes the
// browser to dismiss the keyboard first (blur event), swallowing the tap.
// The button only receives the click on a second tap. Fix: intercept
// touchstart on buttons while keyboard is visible, preventDefault to stop
// the dismiss-swallows-tap behavior, and trigger the click programmatically.
if (MobileDetection.isTouchDevice()) {
const addKeyboardTapFix = (container) => {
if (!container) return;
container.addEventListener('touchstart', (e) => {
if (!KeyboardHandler.keyboardVisible) return;
const btn = e.target.closest('button');
if (!btn) return;
e.preventDefault();
btn.click();
// Refocus terminal so keyboard stays open (e.g. voice input button)
if (typeof app !== 'undefined' && app.terminal) {
app.terminal.focus();
}
}, { passive: false });
};
addKeyboardTapFix(document.querySelector('.toolbar'));
addKeyboardTapFix(document.querySelector('.welcome-overlay'));
}
// System stats polling deferred until sessions exist (started in handleInit/session:created)
// Setup online/offline detection
this.setupOnlineDetection();
// Load server-stored settings (async, re-applies visibility after load)
this.loadAppSettingsFromServer(settingsPromise).then(() => {
this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyLocalization();
this.applySessionListLayout();
// A fresh device seeding tabOrientation from the server would otherwise
// show no rail until a resize or a settings save: the boot-time call ran
// before this async load resolved. Must stay AFTER applySessionListLayout
// (same ordering rule as the settings-save path).
this.applyTabOrientation?.();
this.applyMonitorVisibility();
this.applyLineageLineSettings?.();
// 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();
}
});
// Hide loading skeleton now that the app shell is ready
document.body.classList.add('app-loaded');
}
_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');
_crashDiag.log('WEBGL_LOST');
this._disableWebGLSticky('context-lost');
this._disposeWebGLObserver();
this._webglAddon?.dispose();
this._webglAddon = null;
this._scheduleTerminalRepaint();
});
this.terminal.loadAddon(this._webglAddon);
console.log('[CRASH-DIAG] WebGL renderer enabled');
this._installWebGLLongTaskGuard();
} catch (_e) { /* WebGL2 unavailable — canvas renderer used */ }
}
/**
* Watch for sustained main-thread stalls that indicate WebGL/GPU trouble.
* 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.
*/
_installWebGLLongTaskGuard() {
if (typeof PerformanceObserver === 'undefined' || this._webglLongTaskObserver) return;
const installedAt = performance.now();
const recent = [];
try {
this._webglLongTaskObserver = new PerformanceObserver((list) => {
if (!this._webglAddon) return;
// ⚠️ The observer sees EVERY long task on the page. While the tile grid
// owns the terminal the main terminal is parked and draws nothing; the
// long tasks are tile renders and replays (DOM renderers), and counting
// them would write the sticky 7-day WebGL disable for no WebGL reason.
if (this._tilesOwnTerminal?.()) return;
const now = performance.now();
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`);
_crashDiag.log(`WEBGL_FALLBACK: ${recent.length}`);
this._disableWebGLSticky('long-tasks');
this._disposeWebGLObserver();
this._webglAddon?.dispose();
this._webglAddon = null;
this._scheduleTerminalRepaint();
}
});
this._webglLongTaskObserver.observe({ type: 'longtask', buffered: false });
} catch { /* longtask not supported */ }
}
/**
* 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;
}
/**
* 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 {}
});
}
_disableWebGLSticky(reason) {
try {
localStorage.setItem('codeman-webgl-disabled', JSON.stringify({ reason, at: Date.now() }));
} catch {}
}
// ═══════════════════════════════════════════════════════════════
// Event Listeners (Keyboard Shortcuts, Resize, Beforeunload)
// ═══════════════════════════════════════════════════════════════
setupEventListeners() {
// 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(),
toggleSessionSidebar: () => this.toggleSessionSidebar(),
};
// Use capture to handle before terminal
document.addEventListener('keydown', (e) => {
// A field that exists to show what a key does (Settings → Key tester, `data-raw-keys`) must
// receive every chord untouched. Without this, probing Ctrl+W killed the active session,
// Ctrl+L cleared the terminal and Escape closed Settings: this listener runs in the capture
// phase, before the field's own handler. Must stay the first statement.
if (e.target?.closest?.('[data-raw-keys]')) return;
// Don't intercept keys during CJK IME composition
if (e.isComposing || e.keyCode === 229) return;
if (this.shouldOpenCommandPaletteFromShortcut?.(e)) {
e.preventDefault();
this.openCommandPalette();
return;
}
// Escape - close panels and modals (different logic: no preventDefault, no return)
if (e.key === 'Escape') {
// An open group menu (or a grouped-rail drag) owns this Escape: close
// just that, not every panel behind it.
if (this._tabGroupMenu && this._tabGroupMenuKeydown) {
this._tabGroupMenuKeydown(e);
return;
}
if (this._tabLayoutDrag?.active && this._tabLayoutDragKeydown) {
this._tabLayoutDragKeydown(e);
return;
}
// So does the Tiles count menu: it closes alone and gives the keyboard
// back to the Tiles button (tile-grid.js).
if (this._tileCountMenu) {
this.closeTileCountMenu({ refocus: true });
return;
}
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
this.closeSessionManager();
this.closeCommandPalette?.();
this.closeShortcutOverlay?.();
// Overlay layouts only: below 1024px the sidebar is a modal off-canvas
// drawer over the terminal, so Escape must close it. The docked desktop
// sidebar is chrome, not a dialog — collapsing it would be a surprise.
if (this._isSessionSidebarOverlay() &&
this.isSessionSidebarActive() && !this.isSessionSidebarCollapsed()) {
this.toggleSessionSidebar();
document.getElementById('sidebarToggleBtn')?.focus();
}
}
// 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;
// Sessions occupy 1..N and web tabs continue from N+1, matching the
// numbers actually painted on the tabs. Resolve through the same
// live-session projection the render paints: sessionOrder can
// transiently hold a dead id (delete raced against the order sync),
// and raw indexing then names the wrong tab for every key to its
// right, web tabs included.
const live = this.sessionOrder.filter((id) => this.sessions.has(id));
if (idx < live.length) {
e.preventDefault();
this.selectSession(live[idx]);
} else {
const webIdx = idx - live.length;
const webId = (this.webviewOrder || [])[webIdx];
if (webId) {
e.preventDefault();
this.openWebview(webId);
}
}
return;
}
if (e.code === 'BracketLeft') {
e.preventDefault();
this.prevSession();
return;
}
if (e.code === 'BracketRight') {
e.preventDefault();
this.nextSession();
return;
}
}
// Tile grid chords: only where they apply (tile-grid.js tileShortcutFor),
// so outside the grid Alt+Shift+Arrows still reach the terminal.
const tileShortcut = this.tileShortcutFor?.(e);
if (tileShortcut) {
e.preventDefault();
this.runTileShortcut(tileShortcut);
return;
}
// 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)) {
e.preventDefault();
action();
return;
}
}
}, true); // Use capture phase to handle before terminal
// Token stats click handler (with guard to prevent duplicate handlers on reconnect)
const tokenEl = this.$('headerTokens');
if (tokenEl && !tokenEl._statsHandlerAttached) {
tokenEl.classList.add('clickable');
tokenEl._statsHandlerAttached = true;
tokenEl.addEventListener('click', () => this.openTokenStats());
}
// Color picker for session customization
this.setupColorPicker();
}
// ═══════════════════════════════════════════════════════════════
// SSE Connection
// ═══════════════════════════════════════════════════════════════
/**
* The session id the SSE filter names for `sessionId`: itself, or while the
* tile grid owns the terminal the grid's fixed filter (TILE_GRID_SSE_FILTER,
* constants.js), which no session matches. Both places that set the filter
* ask here: the live re-subscribe below and the connect URL (connectSSE),
* which an SSE reconnect rebuilds with the grid still open.
*/
_sseFilterSessionId(sessionId) {
if (this._tilesOwnTerminal?.()) return window.CodemanTileGrid?.TILE_GRID_SSE_FILTER || sessionId;
return sessionId;
}
/**
* 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 filterId = this._sseFilterSessionId(sessionId);
const body = JSON.stringify({
clientId: this._clientId,
sessions: filterId ? [filterId] : null,
});
fetch('/api/events/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
}).catch(() => { /* non-fatal */ });
} catch { /* non-fatal */ }
}
// ══════════════════════════════════════════════════════════════════════
// 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__;
}
// Strip the reverse-proxy base so the match works under a sub-path mount.
const base = window.CodemanBase?.base || '';
let path = location.pathname;
if (base && path.startsWith(base)) path = path.slice(base.length) || '/';
const m = path.match(/^\/session\/([^/]+)\/?$/);
return m ? decodeURIComponent(m[1]) : null;
} catch { return null; }
}
/** Read a `#session=<id>` link off the URL and drop the fragment. The next
* link to the same session is then a change the browser reports, even
* after you have clicked away to another tab. Returns the id or null. */
_takeUrlSession() {
const id = window.CodemanUrlSession?.sessionIdFromFragment(location.hash) ?? null;
if (id) {
try { history.replaceState(history.state, '', location.pathname + location.search); } catch {}
}
return id;
}
/** Show the session a `#session=<id>` link asked for, once the session list
* has it. A page that has just created a session can link to it before
* session:created arrives here, so an unknown id stays pending (for at most
* URL_SESSION_WAIT_MS) and _onSessionCreated tries again.
*
* ⚠️ The selection is `auto`. The page that set the fragment may be a
* script, and this window may not even be in front, so following a link is
* not a human looking at the session and must not spend its idle alert. */
_selectUrlSession() {
const id = this._urlSessionId;
if (!id) return false;
if (!this.sessions.has(id)) {
this._armUrlSessionWait(id);
return false;
}
this._retireUrlSession();
// Following a link is navigation: a tiled id focuses its tile, any other
// leaves the tile grid for the single view (the grid is remembered).
this.selectSession(id, { auto: true, leaveTiles: true });
return true;
}
/** Bound the wait for a link whose id the session list does not have. A
* stale link (that session is closed), a typo, or in multi-user mode another
* user's session (never in this client's list) would otherwise wait with
* nothing on screen, and take the tab whenever a matching session turned up.
* One timer per link: handleInit running again (an SSE reconnect) does not
* restart it, and every way a link ends goes through _retireUrlSession. */
_armUrlSessionWait(id) {
if (this._urlSessionWaitTimer) return;
this._urlSessionWaitTimer = setTimeout(() => {
this._urlSessionWaitTimer = null;
if (this._urlSessionId !== id) return;
// Listed by a path other than session:created (a session:updated): select it.
if (this.sessions.has(id)) {
this._selectUrlSession();
return;
}
this._retireUrlSession();
this.showToast?.('Session not found', 'warning');
}, URL_SESSION_WAIT_MS);
}
/** Drop a waiting `#session=<id>` link and its timer: the link was followed,
* replaced by a newer one, timed out, or the user chose something else
* (another tab, Home, a web tab). */
_retireUrlSession() {
this._urlSessionId = null;
if (this._urlSessionWaitTimer) {
clearTimeout(this._urlSessionWaitTimer);
this._urlSessionWaitTimer = 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;
// 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;
// A native wrapper (an Android WebView app) has no browser pop-ups, but can
// open the solo URL in a window of its own, beside this one on a foldable or
// a split screen. There is no WindowProxy to poll, so the tab is tracked the
// way a dashboard reload tracks it: the solo window's channel announcements
// plus the roll-call liveness check. Without a channel there is no roll-call
// either, so a hosted tab could never re-dock: refuse before asking the host.
const hosted = this.hasHostWindows() && !this.windowChannel
? false
: this.openInHostWindow(CodemanBase.url('/session/' + encodeURIComponent(id)));
if (hosted !== null) {
if (!hosted) {
this.showToast?.('Could not open a new window for this session', 'error');
return;
}
this._markDetached(id, true);
this._postWindowMessage({ type: 'detached', id });
return;
}
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
let win = null;
try { win = window.open(CodemanBase.url('/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 {}
}
/**
* The embedding app's window opener, when there is one. A native wrapper
* exposes `window.CodemanHost.openWindow(absoluteUrl)` (anything but false
* counts as opened) to say it can put a page in a window of its own; browsers
* never define it.
* @returns {boolean} whether a host window opener is present
*/
hasHostWindows() {
try {
return typeof window !== 'undefined' && typeof window.CodemanHost?.openWindow === 'function';
} catch { return false; }
}
/**
* The tab pop-out setting, defaulting ON under a host that opens windows.
* The one resolver for the tab icon, App Settings and the tab action menu.
* @param {object} settings stored per-device App Settings
* @param {object} [defaults] the device's default settings
* @returns {boolean} whether the pop-out control shows
*/
tabDetachButtonEnabled(settings, defaults = {}) {
return settings?.showTabDetachButton ?? (this.hasHostWindows() || (defaults?.showTabDetachButton ?? false));
}
/**
* Open an http(s) URL in a host window, usually Codeman's own origin (a saved
* web tab passes its own).
* @param {string} url absolute or base-relative URL
* @returns {boolean|null} null when there is no host (use window.open),
* otherwise whether the host opened a window
*/
openInHostWindow(url) {
if (!this.hasHostWindows()) return null;
try {
return window.CodemanHost.openWindow(new URL(url, location.href).href) !== false;
} catch { return false; }
}
/** 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;
}
/** 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, and take its
* sizing back: the popup owned the pane while it was open, so the dashboard's
* record of it is stale and the session it is showing needs re-measuring.
* ⚠️ Not idempotent — each call re-asserts, so a path that redocks twice for
* one close sends two SIGWINCHs. */
_redock(id) {
const t = this._detachWatchTimers.get(id);
if (t) { clearInterval(t); this._detachWatchTimers.delete(id); }
this._cancelPendingRedock(id);
this._detachOrphanStrikes.delete(id);
this.detachedWindows.delete(id);
this._markDetached(id, false);
// While the popup owned this session the dashboard sent no resizes, so
// `_lastResizeDims` — one value for the whole window — no longer describes
// the PTY, which the popup has been sizing. Clearing it makes the next
// sendResize report truthfully, on every redock path rather than only the
// active one: `selectSession` reads that answer to decide whether to wait
// for the TUI's redraw, and a false "unchanged" makes it fetch the frame
// before the redraw lands.
this._lastResizeDims = null;
// Sizing comes back with the session. `force` buys a guaranteed repaint for
// the case where popup and dashboard happened to agree on a size; the server
// already resizes on its own comparison against the real pane whenever the
// two differ.
if (this.sessions.has(id) && id === this.activeSessionId) {
this.sendResize(id, { force: true })?.catch?.(() => {});
}
}
/** 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); }
}
/** 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);
// A popped-out session's window owns its PTY size now, so it leaves the
// tile grid (one place per session in this browser tab).
if (on && this._tileGrid?.has(id)) this.removeTile(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
// (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.
this._postWindowMessage({ type: 'roll-call' });
this._startDetachLiveness();
}
}
_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;
// A host window ignores window.close()/focus() from script it did not
// open by window.open, so ask the host when it offers the call.
if (msg.type === 'close-request') { this._closeSoloWindow(); }
else if (msg.type === 'focus-request') {
try { if (typeof window.CodemanHost?.focusWindow === 'function') window.CodemanHost.focusWindow(); else window.focus(); } catch {}
}
return;
}
// Dashboard side.
if (msg.type === 'detached' && msg.id) {
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
this._detachOrphanStrikes.delete(msg.id); // any answer clears accumulated misses
this._markDetached(msg.id, true);
} else if (msg.type === 'redocked' && msg.id) {
this._scheduleRedock(msg.id); // defer: a popup reload fires redocked→detached; grace avoids a badge blip
} else if (msg.type === 'detach-request' && msg.id) {
// Future gesture hook: another window asks the dashboard to detach a tab.
this.detachSession(msg.id);
}
}
/** 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' });
// 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).
setTimeout(() => {
if (!this._detachPingPending) return;
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);
}
this._detachPingPending = null;
}, 1200);
}
/** Solo window: close itself (the re-dock button and a dashboard close-request). */
_closeSoloWindow() {
try {
if (typeof window.CodemanHost?.closeWindow === 'function') window.CodemanHost.closeWindow();
else window.close();
} catch {}
}
/** 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). `auto`: the
// window is opening its own target, which is not a human checking on it.
this.activeSessionId = null;
this.selectSession(this.soloSessionId, { auto: true });
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 + ' — ' + (window.CodemanI18n?.displayName || '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="app._closeSoloWindow()">Close window</button>';
document.body.appendChild(el);
document.title = (window.codemanT?.('Session ended') || 'Session ended')
+ ' — ' + (window.CodemanI18n?.displayName || 'Codeman');
}
connectSSE() {
// Check if browser is offline
if (!navigator.onLine) {
this.setConnectionStatus('offline');
return;
}
// Clear any pending reconnect timeout to prevent duplicate connections
this._clearTimer('sseReconnectTimeout');
// Same discipline for the staleness watchdog: connectSSE() runs on every
// reconnect and is the only teardown path this page-lifetime interval has,
// so clearing it anywhere else (or not at all) stacks intervals.
if (this._sseStaleWatchdog) {
clearInterval(this._sseStaleWatchdog);
this._sseStaleWatchdog = null;
}
// Clean up existing SSE listeners before creating new connection (prevents listener accumulation)
if (this._sseListenerCleanup) {
this._sseListenerCleanup();
this._sseListenerCleanup = null;
}
// Close existing EventSource before creating new one to prevent duplicate connections
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
// Show connecting state
if (this.reconnectAttempts === 0) {
this.setConnectionStatus('connecting');
} else {
this.setConnectionStatus('reconnecting');
}
// 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 });
const _sseFilterId = this._sseFilterSessionId(this.activeSessionId);
if (_sseFilterId) _sseParams.set('sessions', _sseFilterId);
this.eventSource = new EventSource(CodemanBase.url(`/api/events?${_sseParams.toString()}`));
// Store all event listeners for cleanup on reconnect.
//
// Every handler is wrapped so ANY frame that arrives stamps the liveness
// clock the staleness watchdog reads. Doing it here (rather than at the
// three separate registration sites below) is what keeps a future
// addListener() call from silently opting out of it.
const listeners = [];
const addListener = (event, handler) => {
const stamped = (e) => {
this._sseLastMessageAt = Date.now();
handler(e);
};
this.eventSource.addEventListener(event, stamped);
listeners.push({ event, handler: stamped });
};
// Create cleanup function to remove all listeners
this._sseListenerCleanup = () => {
for (const { event, handler } of listeners) {
if (this.eventSource) {
this.eventSource.removeEventListener(event, handler);
}
}
listeners.length = 0;
};
this.eventSource.onopen = () => {
this.reconnectAttempts = 0;
// Start the liveness clock here, not at the first frame: the watchdog
// only ever fires while the status is 'connected', and this is the
// moment that becomes true.
this._sseLastMessageAt = Date.now();
this.setConnectionStatus('connected');
};
this.eventSource.onerror = () => {
this.reconnectAttempts++;
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.setConnectionStatus('disconnected');
} else {
this.setConnectionStatus('reconnecting');
}
// Close the failed connection before scheduling reconnect
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
// Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts)
this._clearTimer('sseReconnectTimeout');
// Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s
// Fast first retry (200ms) for server-restart case (COM deploy),
// then ramp up for real network issues.
const delay = this.reconnectAttempts <= 1 ? 200
: Math.min(500 * Math.pow(2, this.reconnectAttempts - 2), 30000);
// Feeds the "Retrying in Ns" countdown. With a 30s cap on the backoff, a
// silent wait that long is indistinguishable from a hung app.
this._nextSseRetryAt = Date.now() + delay;
this._updateConnectionLossUi();
this.sseReconnectTimeout = setTimeout(() => this.connectSSE(), delay);
};
// Create stable handler wrappers once (reused across reconnects so
// removeEventListener always matches the original reference)
if (!this._sseHandlerWrappers) {
this._sseHandlerWrappers = new Map();
for (const [event, method] of _SSE_HANDLER_MAP) {
const fn = this[method];
const wsOwnsTerminal =
method === '_onSSETerminal' ||
method === '_onSSENeedsRefresh' ||
method === '_onSSEClearTerminal';
this._sseHandlerWrappers.set(event, (e) => {
// While WS owns terminal I/O, the parallel SSE stream is redundant.
// Drop it before JSON.parse so a busy terminal cannot turn duplicate
// SSE traffic/backpressure into another expensive buffer replay.
if (wsOwnsTerminal && this._wsReady) return;
try {
fn.call(this, e.data ? JSON.parse(e.data) : {});
} catch (err) {
console.error(`[SSE] Error handling ${event}:`, err);
}
});
}
}
// Register all SSE event handlers via centralized map
for (const [event] of _SSE_HANDLER_MAP) {
addListener(event, this._sseHandlerWrappers.get(event));
}
// 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
// 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]) {
addListener(event, () => this._onSessionListMaybeChanged());
}
// 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);
}
});
// 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);
}
});
// Custom Model Endpoint Profiles: a session's own model got evicted on llama-swap by
// another session's activity, detected AFTER the fact by a periodic server sweep (there
// is no push notification from llama-swap itself) — see detectCustomModelSwapDisplacements
// in custom-model-routes.ts. Global toast rather than a per-tab indicator: the displaced
// session need not be the one currently open, and the whole point is telling the user
// BEFORE they type into it expecting the model they picked.
addListener(SSE_EVENTS.CUSTOM_MODEL_SWAPPED_OUT, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
this.showToast(
`${d.sessionName || d.sessionId}'s model (${d.previousModel}) was swapped out on llama-swap by another ` +
`session — currently loaded: ${d.currentlyLoadedModel}. Sending a message there will reload it.`,
'warning',
{ duration: 0 }
);
} catch (err) {
console.error('[SSE] custom model swapped out:', err);
}
});
// Multi-user admin: live-refresh whichever admin views (panel/Users tab) are open.
addListener(SSE_EVENTS.ADMIN_USERS_CHANGED, () => {
window.codemanAdmin?.onUsersChanged?.();
});
// 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);
}
});
// 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();
});
// Liveness heartbeat. The handler is deliberately empty: the whole point
// is the stamp inherited from addListener's wrapper. It still has to be
// REGISTERED: EventSource only dispatches named events that have a
// listener, so without this the frame arrives on the wire and is dropped
// before it can prove the stream is alive.
addListener(SSE_EVENTS.HEARTBEAT, () => {});
// Watchdog: a stream that goes quiet without erroring is invisible to
// onerror, so poll the pure staleness policy and rebuild the connection
// ourselves. 5s granularity against a 45s threshold: cheap, and it keeps
// the worst-case detection lag well under a heartbeat interval.
this._sseStaleWatchdog = setInterval(() => this._checkSseStale(), 5000);
}
/**
* Force a reconnect if the SSE stream has gone quiet while still claiming to
* be connected. Called by the 5s watchdog and on tab-visible.
*
* Recovery needs no new sync path: the reconnect re-runs `handleInit`, which
* already calls `_resetAllAppState()` and rebuilds everything from the
* server. The connection-loss UI needs nothing either: `connectSSE()` sets
* status 'connecting' (reconnectAttempts was zeroed by onopen), and the 2.5s
* grace in computeConnectionLossUi means a stream that heals in 200ms shows
* nothing at all.
*/
_checkSseStale() {
const policy = window.CodemanSseStale;
if (!policy) return;
const now = Date.now();
const stale = policy.compute({
lastMessageAt: this._sseLastMessageAt,
now,
status: this._connectionStatus,
isOnline: this.isOnline,
timeoutMs: this._sseStaleTimeoutMs,
});
if (!stale) return;
// If a middlebox ever strips or delays heartbeats, the failure mode is
// "silently reconnects every 45s", and a field report of that would be
// undebuggable without this line.
console.log(
`[SSE] stream stale: no frame for ${now - this._sseLastMessageAt}ms ` +
`(threshold ${this._sseStaleTimeoutMs}ms), forcing reconnect`
);
this.connectSSE();
}
// ═══════════════════════════════════════════════════════════════
// SSE Event Handlers
// ═══════════════════════════════════════════════════════════════
// Each _on* method receives pre-parsed SSE data (JSON.parse done in connectSSE loop).
// Async handlers have their own internal try/catch for fetch errors.
_onInit(data) {
_crashDiag.log(`INIT: ${data.sessions?.length || 0} sessions`);
this.handleInit(data);
// Start the remote-host reachability poller even if no session switch follows
// (a page loaded with the remote tab already active) — see host-wake-ui.js.
this._ensureHostWakePoller?.();
}
_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();
}
// Idempotent per id: the POST response and the session:created event both
// land here, and a batch launched together cascades in creation order.
this.markSessionTabEntering?.(data.id);
// The pane is one shared element, so it is only marked here and played when
// this session is actually selected (see selectSession).
this.markTerminalEntering?.(data.id);
// A spawned session's lineage arc draws in with the tab. Keyed the same way
// session-lineage.js tags its paths; a no-op unless a line-entrance theme is on.
if (data.parentSessionId) this.markConnectionLineEntering?.('lineage:' + data.id);
this.renderSessionTabs();
this.updateCost();
// Start stats polling when first session appears
if (this.sessions.size === 1) this.startSystemStatsPolling();
if (this._urlSessionId === data.id) this._selectUrlSession();
}
_onSessionUpdated(data) {
const session = data.session || data;
const oldSession = this.sessions.get(session.id);
const claudeSessionIdJustSet = session.claudeSessionId && (!oldSession || !oldSession.claudeSessionId);
this.sessions.set(session.id, session);
this.renderSessionTabs();
this.updateCost();
// Update tokens display if this is the active session
if (session.id === this.activeSessionId && session.tokens) {
this.updateRespawnTokens(session.tokens);
}
// Update parentSessionName for any subagents belonging to this session
// (fixes stale name display after session rename)
this.updateSubagentParentNames(session.id);
// If claudeSessionId was just set, re-check orphan subagents
// This connects subagents that were waiting for the session to identify itself
if (claudeSessionIdJustSet) {
this.recheckOrphanSubagents();
// Update connection lines after DOM settles (ensure tabs are rendered)
requestAnimationFrame(() => {
this.updateConnectionLines();
});
}
}
_onSessionDeleted(data) {
if (this._wsSessionId === data.id) this._disconnectWs();
// 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);
this._cleanupSessionData(data.id);
// ⚠️ Skip the whole active-session handoff while THIS tab is closing that
// session: closeSession() owns the follow-up selection and moves you to the
// next tab, so acting here would race it and flash the welcome screen (or
// strand you on it) for a close the user initiated right here. A delete from
// anywhere else still lands on the home screen, which is the honest answer
// when the thing you were looking at was taken away.
if (this.activeSessionId === data.id && !this._closingSessions.has(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();
}
// SSE wrappers — skip terminal events while WebSocket owns active terminal I/O.
// WS handler calls the underlying _onSession* methods directly.
_onSSETerminal(data) {
if (this._wsReady) return;
this._onSessionTerminal(data);
}
_onSSENeedsRefresh(data) {
if (this._wsReady) return;
this._onSessionNeedsRefresh(data);
}
_onSSEClearTerminal(data) {
if (this._wsReady) return;
this._onSessionClearTerminal(data);
}
/**
* How a buffer load that just fetched `payload` must end.
*
* A tmux pane capture is a point-in-time frame, so nothing that reached the
* browser after the response headers can already be in it. Such a load
* replays exactly that tail; discarding it drops the CLI's output for the
* rest of the load window, and its next partial redraw then lands on a frame
* the terminal never received.
*
* A `history` payload is the server's byte buffer alone: the direct-PTY
* fallback, or a mux pane whose capture came back empty. The route reads
* that buffer in the same synchronous tick it takes the capture, so it is
* current up to the route's own read and no further, which is the same
* exposure. It deliberately keeps the pre-existing discard all the same:
* both cases are rare, neither has been measured, and a duplicated Ink
* redraw is more visible than a few milliseconds of missing output.
* `capturedFromMux` below is the one line to widen if either turns out to
* matter.
*
* `headersReceivedAt` is the caller's own `performance.now()` reading from
* the moment the response arrived, compared only against other client-side
* readings, so there is no clock skew to worry about.
*
* What this cutoff does NOT cover, and there are two contributors. The
* server appends output to the byte buffer and emits it in the same tick,
* but BROADCASTS on a batch timer (8ms over WebSocket, 16 to 50ms over SSE),
* and the terminal route runs synchronously from `capture-pane` to its
* return, so a batch already pending when the capture ran leaves the server
* after the reply, arrives after `headersReceivedAt`, and is replayed
* although the capture holds it. Separately, `captureActivePaneBuffer` is
* `execSync`, which blocks the event loop for the whole capture: anything
* tmux had already painted into the pane that the server had not yet read
* from the attach PTY is in the capture too, is broadcast only after the
* reply, and replays the same way. The duplicate is one batch interval plus
* one capture wide, against a recovery window that spans the whole chunked
* write. Closing it belongs on the server: flush that session's pending
* batch before taking the capture.
*
* @param {{source?: string}} payload - The parsed `data` of a terminal response.
* @param {number} headersReceivedAt - When that response reached this client.
* @returns {{flushQueued: boolean, since: number}} Options for `_finishBufferLoad`.
*/
_bufferLoadFinishOpts(payload, headersReceivedAt) {
const capturedFromMux = payload?.source === 'mux-visible' || payload?.source === 'mux-full-history';
return { flushQueued: capturedFromMux, since: headersReceivedAt };
}
_onSessionTerminal(data) {
// Tile grid open: the main terminal is parked and its socket closed, so the
// SSE fallback would write the focused tile's output into a hidden xterm.
// The tiles carry their own output over their own sockets.
if (this._tilesOwnTerminal?.()) return;
if (data.id === this.activeSessionId) {
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
// Hard cap all app-owned render queues plus the one xterm chunk currently
// parsing. Check the incoming frame too; otherwise a single large frame can
// jump over the cap. Dropped data is recovered from the canonical buffer.
const queued = (this.pendingWrites?.reduce((s, w) => s + w.length, 0) || 0)
+ (this.flickerFilterBuffer?.length || 0)
+ (this._loadBufferQueue?.reduce((s, w) => s + w.data.length, 0) || 0)
+ (this._terminalWriteInFlightBytes || 0);
if (queued + data.data.length > 131072) { // 128KB — drop to prevent accumulation
// The bytes are gone from the stream now, so the recovery is the only
// thing that puts this terminal back in step with the PTY. It also
// writes the crash-trail line, once per debounce window: logged here,
// one line per dropped frame evicted the whole 50-entry trail in
// under a second.
this._scheduleDroppedOutputRecovery(data.id, 0, queued);
return;
}
this.batchTerminalWrite(data.data);
}
}
/**
* Put the terminal back in step after a dropped frame, and keep trying until
* something actually repaints.
*
* ⚠️ A fire-and-forget timer is not a recovery, which is what this used to be:
* it nulled its own handle and then called `_onSessionNeedsRefresh()`, whose
* early returns are most likely to fire during the very burst that caused the
* drop. A skipped refresh lost the recovery with nothing left to retry it, so
* the hole stayed in the stream — and a hole in a TUI byte stream is a
* desynced cursor, which is muffled text (issue #464).
*
* Debounced by the same 2s as before, so a sustained burst still collapses
* into one attempt rather than hammering the API; bounded by
* `DROP_RECOVERY_MAX_ATTEMPTS`, because the early returns it retries past
* are transient contention. A refresh that died at the fetch DEADLINE is
* not retried: that is a stalled link, not contention, and each retry would
* be another `?full=1` capture waiting out a deadline of up to two minutes.
* Giving up leaves exactly what the old code left, so the floor is no worse.
*
* @param {string} sessionId - the session whose output was dropped
* @param {number} [attempt] - zero-based, for the bound
* @param {number} [queuedBytes] - render-queue bytes at the drop, for the crash trail
*/
_scheduleDroppedOutputRecovery(sessionId, attempt = 0, queuedBytes) {
if (!sessionId || this._clientDropRecoveryTimer) return;
// Nothing of the main terminal's to recover while the tile grid owns it.
if (this._tilesOwnTerminal?.()) return;
// Behind the debounce guard: one line per window, not per dropped frame.
if (Number.isFinite(queuedBytes)) _crashDiag.log(`TERMINAL DROP: ${(queuedBytes / 1024).toFixed(0)}KB queued`);
this._clientDropRecoveryTimer = setTimeout(async () => {
this._clientDropRecoveryTimer = null;
let repainted = false;
let timedOut = false;
try {
const result = await this._onSessionNeedsRefresh({ id: sessionId });
repainted = result === true;
timedOut = result === 'deadline';
} catch {
// Treated as "did not repaint" — retrying is the entire point of this.
}
const retry = window.CodemanDroppedOutput.shouldRetryDroppedOutputRecovery({
repainted,
timedOut,
attempt,
stillActive: this.activeSessionId === sessionId,
});
if (retry) {
_crashDiag.log(`DROP RECOVERY: attempt ${attempt + 1} did not repaint, retrying`);
this._scheduleDroppedOutputRecovery(sessionId, attempt + 1);
}
// Read through `window.` like terminal-ui.js does with its own constants:
// a bare global resolves in a browser but not in the vm harnesses the gate
// runs app.js under, and this body executes inside a timer where a
// ReferenceError would be swallowed — taking the recovery with it.
}, window.CodemanDroppedOutput.DROP_RECOVERY_DELAY_MS);
}
// ═══════════════════════════════════════════════════════════════
// Response Viewer — native-scroll panel for reading full Claude responses
// ═══════════════════════════════════════════════════════════════
/** Strip dangerous elements and attributes from HTML (XSS prevention) */
_sanitizeHtml(html) {
if (typeof window !== 'undefined' && typeof window.sanitizeMarkdownHtml === 'function') {
return window.sanitizeMarkdownHtml(html);
}
// Fail closed: DOMPurify unavailable — never return un-sanitized HTML.
return String(html == null ? '' : html)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* Strip ANSI escape sequences and Claude CLI chrome (status bar, hints,
* spinner, progress bar) from a terminal buffer so the response viewer can
* show just the conversational text when the JSONL transcript is missing.
*/
_cleanTerminalBuffer(buf) {
const stripped = buf
// CSI sequences — params (0x30-0x3F includes digits, ?, ;, <, =, >),
// intermediates (0x20-0x2F), final byte (0x40-0x7E). Catches \x1b[>c,
// \x1b[>q, \x1b[?25l etc. that the previous regex missed.
.replace(/\x1b\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g, '')
// OSC sequences (window titles etc.) terminated by BEL or ST
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
// DCS / APC / PM / SOS sequences
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
// SS2/SS3 + charset selects + single-char escapes
.replace(/\x1b[NO()][A-Z0-9]?/g, '')
.replace(/\x1b[>=<78cDEHM]/g, '')
// Stray control chars (except \t \n)
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// Drop Claude CLI chrome lines that aren't part of the response.
const CHROME_PATTERNS = [
/^\s*❯\s*/, // shell prompt
/^\s*[⏵⏺⏸⏹]+\s*/, // status glyphs
/^\s*✻\s*(Crunching|Crunched|Thinking)/i, // spinner lines
/bypass permissions/i,
/\bshift\+tab to cycle\b/i,
/^\s*focus\s*$/,
/^\s*new task\?/i,
/\/clear to save/i,
/^\s*─{5,}\s*$/, // horizontal dividers
/\[(Opus|Sonnet|Haiku|GPT|Claude)[\s\S]*(tokens?|\$|¥|%|↑|↓)/i, // status bar
/^\s*\[\d+[km]?\/\d+[km]?\]/i, // token counter
/[█░▓▒]{3,}/, // progress bar
/^\s*\(.*\s*(tokens?|context).*\)\s*$/i,
];
const lines = stripped.split('\n');
const kept = lines.filter((line) => {
const trimmed = line.trim();
if (!trimmed) return true; // keep blanks so paragraphs survive
return !CHROME_PATTERNS.some((re) => re.test(line));
});
return kept
.join('\n')
.replace(/[ \t]+$/gm, '')
.replace(/\n{4,}/g, '\n\n\n')
.trim();
}
/**
* Wrap ASCII/box diagrams in fenced code blocks so marked.js preserves whitespace.
* Claude often emits box-drawing diagrams without triple-backticks; without this
* step, HTML collapses the whitespace and the diagram becomes unreadable prose.
*/
_preprocessAsciiArt(text) {
// Only trigger on characters that rarely appear in prose:
// U+2500-U+257F Box Drawing (─│┌┐└┘├┤┬┴┼╔╗╚╝═║)
// U+2580-U+259F Block Elements (▀▄█▌▐░▒▓, progress bars)
// Deliberately excluded:
// U+2190-U+21FF Arrows (→←↑↓⇒ — common rhetorical prose)
// U+25A0-U+25FF Geometric Shapes (●○■□◆◇ — common bullets)
// Triggering on those would wrap numbered lists / prose that merely uses
// arrows in code blocks and break their markdown rendering.
const BOX_PATTERN = /[─-╿▀-▟]/;
// Preserve existing fenced code blocks as-is (hide them behind placeholders)
const fenceRe = /```[\s\S]*?```/g;
const placeholders = [];
const masked = text.replace(fenceRe, (m) => {
placeholders.push(m);
return `__CODEMAN_FENCE_${placeholders.length - 1}__`;
});
// 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;
if (chunk.includes('__CODEMAN_FENCE_')) return chunk;
if (BOX_PATTERN.test(chunk)) return '\n```\n' + chunk + '\n```\n';
return chunk;
})
.join('');
return processed.replace(/__CODEMAN_FENCE_(\d+)__/g, (_m, i) => placeholders[Number(i)]);
}
/**
* Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable.
* `breaks` turns every source newline into a <br>: right for chat, where a
* newline is the agent's line break, wrong for a file (the File Viewer passes
* false), where a README hard-wrapped at 80 columns would break at every wrap.
*/
_renderMarkdown(text, { breaks = true } = {}) {
const src = text || '';
if (typeof marked !== 'undefined' && marked.parse) {
try {
const prepared = this._preprocessAsciiArt(src);
let html = this._sanitizeHtml(marked.parse(prepared, { breaks, 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;
// 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.
tmpl.content.querySelectorAll('pre > code').forEach((code) => {
const pre = code.parentElement;
const isDiagram = DIAGRAM_CHAR.test(code.textContent || '');
const wrap = document.createElement('div');
wrap.className = isDiagram ? 'rv-code-wrap rv-diagram-wrap' : 'rv-code-wrap';
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);
}
pre.parentNode.insertBefore(wrap, pre);
wrap.appendChild(actions);
wrap.appendChild(pre);
});
// Links open in a NEW tab.
//
// marked emits a bare `<a href>` and the sanitizer's allowlist has no
// `target`, so a tap in the chat NAVIGATED THE APP AWAY: on a phone that
// unloads the whole dashboard — SSE, terminal buffers, unsent composer
// text — and the OS back gesture reloads it from scratch, which is what
// "links don't open" reads as on mobile, with no middle-click or
// open-in-new-tab affordance to work around it.
//
// This pass runs AFTER sanitizing, so it is the only source of these two
// attributes: whatever an agent wrote is already gone, and `rel` is set on
// the same element in the same breath, so no page Codeman opens ever gets
// a `window.opener` handle back (reverse tabnabbing).
//
// A fragment link stays in-page, and mailto:/tel: are handed to the OS —
// giving those a target just strands an empty tab.
tmpl.content.querySelectorAll('a[href]').forEach((a) => {
const href = a.getAttribute('href') || '';
if (!href || href.startsWith('#') || /^(?:mailto|tel):/i.test(href)) return;
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
return tmpl.innerHTML;
} catch { /* fall through */ }
}
// Fallback: escape HTML and preserve whitespace
const escaped = src.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
}
/**
* 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';
body.addEventListener('click', async (ev) => {
// File path (_linkifyFilePaths): open it in the preview overlay, which
// resolves workspace and out-of-workspace paths alike.
const pathLink = ev.target.closest('a.rv-path');
if (pathLink) {
ev.preventDefault();
ev.stopPropagation();
const filePath = pathLink.dataset.path;
// A rendered document's links name the session the preview was opened
// for (_rebaseFilePreviewMarkdownRefs), which need not be the active tab.
if (filePath) this.openFilePreview(filePath, pathLink.dataset.sessionId || this.activeSessionId);
return;
}
// A `localhost` URL in the agent's answer: from another device that can
// only load through the server, so hand it to a proxied web tab
// (webview-tabs.js). Every other link keeps its new-tab default.
const urlLink = ev.target.closest('a[href]');
if (urlLink && this.openLinkThroughWebTabIfLoopback?.(urlLink.href)) {
ev.preventDefault();
ev.stopPropagation();
return;
}
// 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;
}
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);
});
}
/**
* 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;
}
}
/** Build one response-viewer message so the brief and full views share markup and CSS. */
_buildResponseViewerMessage(text, role, agentLabel, meta) {
const div = document.createElement('div');
const isUser = role === 'user';
div.className = 'rv-message ' + (isUser ? 'rv-msg-user' : 'rv-msg-assistant');
// Consecutive messages from one speaker inside one turn are segments of a
// single utterance: one badge, a hairline seam. Claude emits a median of 3
// messages per turn (p90 11, max 51), so a badge per message would be the
// card spam the old concatenation was introduced to avoid. `meta` is
// optional so the brief view's 3-argument call keeps its exact shape.
const continuation = !!(meta && meta.continuation);
if (continuation) div.classList.add('rv-msg-cont');
if (meta && meta.kind) div.dataset.kind = meta.kind;
if (meta && meta.queued) div.dataset.queued = '1';
if (!continuation) {
const roleBadge = document.createElement('div');
roleBadge.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
roleBadge.textContent = isUser ? 'You' : agentLabel;
div.appendChild(roleBadge);
}
const renderedText = document.createElement('div');
renderedText.className = 'rv-text';
renderedText.innerHTML = this._renderMarkdown(text);
this._linkifyFilePaths(renderedText);
div.appendChild(renderedText);
return div;
}
/**
* Make absolute file paths in a rendered message clickable.
*
* The terminal's link provider never sees these: the response viewer is
* markdown, and a path the agent wrote as prose or inline code renders as
* inert text — so the file it just produced (a screenshot, a report) was one
* copy-paste away from being viewable instead of one click. Same pattern the
* terminal uses (constants.js), same destination (the file-preview overlay).
*
* Walks TEXT NODES and builds anchors with DOM APIs — never innerHTML, and
* never a string rebuild of already-sanitized markup: the source is model
* output. Subtrees already inside an `<a>` are skipped so an autolinked URL
* is never re-cut, and the anchor's textContent is the path verbatim, so
* "copy code" still yields exactly what the agent printed.
*/
_linkifyFilePaths(root) {
if (!root || typeof document === 'undefined') return;
// Guarded: a stale cached constants.js must degrade to plain text, not throw
// out of the middle of rendering a message.
if (typeof absoluteFilePathPattern !== 'function') return;
const pattern = absoluteFilePathPattern();
// Collect first: replacing a node while the walker is positioned on it
// invalidates the traversal.
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const targets = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node.parentElement?.closest('a')) continue;
pattern.lastIndex = 0;
if (pattern.test(node.nodeValue || '')) targets.push(node);
}
for (const node of targets) {
const value = node.nodeValue;
const frag = document.createDocumentFragment();
let cursor = 0;
let match;
pattern.lastIndex = 0;
while ((match = pattern.exec(value)) !== null) {
const path = match[1];
if (match.index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, match.index)));
const link = document.createElement('a');
link.className = 'rv-path';
link.href = '#';
link.dataset.path = path;
link.title = path;
link.textContent = path;
frag.appendChild(link);
cursor = match.index + path.length;
}
if (cursor < value.length) frag.appendChild(document.createTextNode(value.slice(cursor)));
node.parentNode?.replaceChild(frag, node);
}
}
_getResponseViewerAgentLabel() {
const mode = this.sessions.get(this.activeSessionId)?.mode;
return mode === 'codex'
? 'Codex'
: mode === 'gemini'
? 'Gemini'
: mode === 'antigravity'
? 'Antigravity'
: mode === 'pi'
? 'Pi'
: mode === 'grok'
? 'Grok'
: mode === 'deepseek'
? 'DeepSeek'
: mode === 'omp'
? 'OMP'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
}
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).
// `context=turn` asks for the last ANSWERED turn as messages: a Claude
// answer is a median of 3 model messages (p90 11), and `text` alone is
// only the final one — usually a "Done." tail with the substance in the
// rows before it. Readers that know no `turn` context (Codex, the pane
// parser, an older server) answer with `text` only, and that path is
// unchanged below.
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response?context=turn`);
const data = (await res.json())?.data ?? {};
let lastResponse = data.text || '';
const turnMessages = (Array.isArray(data.messages) ? data.messages : []).filter(
(msg) => msg && msg.role === 'assistant' && typeof msg.text === 'string' && msg.text.trim()
);
// Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome.
// Claude + shell only: _cleanTerminalBuffer knows Claude CLI's output, and
// shell sessions have no transcript source at all; for TUI modes
// (codex/opencode/gemini/antigravity) it yields repaint garbage, so a clear
// placeholder beats a messy screen dump there.
const sessionMode = this.sessions.get(this.activeSessionId)?.mode || 'claude';
if (!lastResponse && (sessionMode === 'claude' || sessionMode === 'shell')) {
// The no-param form is capped only by `terminalBufferMaxBytes` (32MB by
// default), so it is the largest body the frontend asks for anywhere —
// it gets the full-history budget, not the tail one.
const termCapture = await this._fetchTerminalCapture(
`/api/sessions/${this.activeSessionId}/terminal`,
{ full: true }
);
const termData = termCapture.json?.data ?? {};
if (termData.terminalBuffer) {
lastResponse = this._cleanTerminalBuffer(termData.terminalBuffer);
}
}
const body = document.getElementById('responseViewerBody');
if (turnMessages.length > 0) {
// The whole last turn, rendered exactly as the full view renders that
// turn: one badge, then badge-less continuation segments. The same
// numeric-`turn` gate as loadFullContext, never same-role adjacency.
const agentLabel = this._getResponseViewerAgentLabel();
body.innerHTML = '';
let previous = null;
for (const msg of turnMessages) {
const continuation = !!previous && typeof msg.turn === 'number' && previous.turn === msg.turn;
body.appendChild(this._buildResponseViewerMessage(msg.text, 'assistant', agentLabel, { ...msg, continuation }));
previous = msg;
}
this._bindResponseViewerInteractions(body);
} else if (lastResponse) {
// Keep the brief view inside the same message wrapper as the full
// conversation view. The wrapper supplies the card, role badge and
// descendant markdown styles that direct body children do not get.
body.innerHTML = '';
body.appendChild(this._buildResponseViewerMessage(lastResponse, 'assistant', this._getResponseViewerAgentLabel()));
this._bindResponseViewerInteractions(body);
} else {
body.textContent =
window.codemanT?.('No response yet — send a message in this session first.') ||
'No response yet — send a message in this session first.';
}
// 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');
// A multi-row turn opens at its NEWEST text, matching loadFullContext's
// "scroll to bottom (latest message)". `scrollTop = 0` was right when the
// brief view was a single card holding the last row; with the whole turn
// rendered, the top is the turn's first narration line and the answer the
// eye button exists to show can be several screens down.
body.scrollTop = turnMessages.length > 1 ? body.scrollHeight : 0;
} catch (err) {
console.error('Failed to load response:', err);
}
}
async loadFullContext() {
if (!this.activeSessionId) return;
const moreBtn = document.getElementById('responseViewerMore');
if (moreBtn) moreBtn.textContent = '...';
try {
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response?context=full`);
const data = (await res.json())?.data ?? {};
const messages = data.messages || [];
const body = document.getElementById('responseViewerBody');
const title = document.getElementById('responseViewerTitle');
if (!body) return;
if (messages.length === 0) {
// Never destroy what the eye button already rendered: the brief view has
// a terminal-buffer fallback (see toggleResponseViewer) that this
// endpoint does not, so an empty full-context result must not wipe a
// real answer the user is reading.
// ⚠️ Idempotent, because More deliberately stays live here: the branch
// returns before the button is hidden so a transcript that appears a
// moment later can still be loaded, and appending would then stack a
// second identical notice on every retry.
// `:scope >` keeps the lookup off model-rendered markdown inside .rv-text.
let notice = body.querySelector(':scope > .rv-notice');
if (!notice) {
notice = document.createElement('div');
notice.className = 'rv-notice';
body.appendChild(notice);
}
const emptyText = 'No full conversation history available for this session';
notice.textContent = window.codemanT?.(emptyText) || emptyText;
return;
}
// Render conversation thread
const agentLabel = this._getResponseViewerAgentLabel();
body.innerHTML = '';
let previous = null;
for (const msg of messages) {
// ⚠️ A numeric `turn` is REQUIRED, never same-role adjacency alone.
// Only the Claude reader emits turns; Codex and the external-CLI pane
// parser emit adjacent assistant/response blocks with no turn at all, and
// an older server emits none either — all three must keep rendering one
// badged card per message exactly as they do today.
const continuation =
!!previous && previous.role === msg.role && typeof msg.turn === 'number' && previous.turn === msg.turn;
body.appendChild(this._buildResponseViewerMessage(msg.text, msg.role, agentLabel, { ...msg, continuation }));
previous = msg;
}
this._bindResponseViewerInteractions(body);
const turns = new Set(messages.filter((msg) => typeof msg.turn === 'number').map((msg) => msg.turn)).size;
if (title) {
title.textContent = turns
? `Conversation (${messages.length} messages, ${turns} turns)`
: `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';
}
}
/**
* Fetch a terminal capture under a deadline.
*
* Every terminal fetch used to run with no timeout at all, including
* `?full=1`, which _maybeRefetchFullHistory itself calls "unbounded-ish work:
* at the default history limit it can be megabytes". On a stalled mobile link
* that request hangs on the browser default with no retry, and the load-state
* machinery stays armed behind it.
*
* The budget scales with what is being asked for and with how many captures
* are already running (see CodemanFetchDeadline): a full scrollback on a slow
* uplink legitimately needs longer than a tail, and eight tabs resuming must
* not all expire together because each assumed it had the link to itself.
*
* An abort surfaces as a rejected promise, which every caller already handles —
* they wrap these in try/catch and log. That is the point: a timeout becomes a
* recoverable error instead of an indefinite hang.
*
* ⚠️ **The body is read HERE, and that is the whole point.** `await fetch()`
* settles on response HEADERS, not the body, so clearing the deadline when it
* resolves leaves the body — the multi-megabyte `?full=1` capture this exists
* for — completely unbounded. Measured against a server that sends headers
* immediately and stalls the body: `fetch()` resolved at 30ms, the timer was
* cleared there, and the body completed at 4026ms unaborted under a 1000ms
* deadline. Reading the body inside the helper is what makes the deadline
* cover the transfer rather than just the handshake. `_terminalCaptureInflight`
* is scoped the same way, so a body still streaming counts toward the budget
* of a capture starting beside it.
*
* Returns the PARSED envelope plus the response headers, because two callers
* read `server-timing`, and `headersAt` because those same callers measure
* header-vs-body time and can no longer observe that moment themselves.
*
* @param {string} url
* @param {{full?: boolean}} [opts]
* @returns {Promise<{json: unknown, headers: Headers|undefined, headersAt: number}>}
*/
async _fetchTerminalCapture(url, opts = {}) {
const deadlineMs =
typeof CodemanFetchDeadline !== 'undefined'
? CodemanFetchDeadline.terminalFetchDeadlineMs({
full: !!opts.full,
inflight: this._terminalCaptureInflight || 0,
})
: 45000;
// AbortSignal.timeout() is not on every browser Codeman supports, so drive
// it from a controller and always clear the timer — an uncancelled one
// would abort a LATER request that reused this controller's signal.
//
// Degrade to a plain fetch where AbortController is missing rather than
// throwing: a capture with no deadline is the behaviour every caller had
// before this helper existed, while a ReferenceError here would take out
// terminal replay entirely. The deadline is a safety net, not a dependency.
const canAbort = typeof AbortController === 'function';
const controller = canAbort ? new AbortController() : null;
const timer = controller ? setTimeout(() => controller.abort(), deadlineMs) : null;
this._terminalCaptureInflight = (this._terminalCaptureInflight || 0) + 1;
try {
const res = await (controller ? fetch(url, { signal: controller.signal }) : fetch(url));
const headersAt = performance.now();
// Still inside the deadline: an abort here rejects the body stream, which
// is exactly the case a header-only timeout could not reach.
const json = await res.json();
return { json, headers: res.headers, headersAt };
} catch (err) {
if (err?.name === 'AbortError') {
_crashDiag.log(`TERMINAL FETCH TIMEOUT after ${deadlineMs}ms`);
}
throw err;
} finally {
if (timer !== null) clearTimeout(timer);
this._terminalCaptureInflight = Math.max(0, (this._terminalCaptureInflight || 1) - 1);
}
}
/**
* Reload this session's buffer from the server.
*
* ⚠️ Returns whether it ACTUALLY reloaded. Four of the paths out of here are
* early returns, and two of them — a buffer load in flight, a refresh already
* owning this session — are most likely to be true during exactly the output
* burst that makes a caller need this. A caller that treats "called" as
* "recovered" silently loses the recovery; `_scheduleDroppedOutputRecovery`
* is the one that cannot afford to.
*
* @returns {Promise<boolean|'deadline'>} true only once a response has been
* applied; 'deadline' when the capture fetch hit its deadline (a stalled
* link, which the dropped-output scheduler does not retry); false otherwise.
*/
async _onSessionNeedsRefresh(event = {}) {
// Server sends this after SSE backpressure clears — terminal data was dropped,
// so reload the buffer to recover from any display corruption.
// Tile grid open: the main terminal is parked, so this would fetch a capture
// for a hidden xterm. Each tile refreshes itself through the grid's queue.
// `false`, never undefined: the drop recovery reads the result.
if (this._tilesOwnTerminal?.()) return false;
const sessionId = this.activeSessionId;
if (event?.id && event.id !== sessionId) return false;
if (!sessionId || !this.terminal) return false;
// Skip if buffer load already in progress — avoids competing clear+rewrite cycles
if (this._isLoadingBuffer) return false;
if (this._terminalRefreshOwner?.sessionId === sessionId) return false;
const refreshOwner = { sessionId };
this._terminalRefreshOwner = refreshOwner;
try {
// A shell can retain a multi-megabyte/100k-line tmux history. Automatic
// recovery stays bounded just like normal shell selection; only the
// explicit "Load full history" action is allowed to pay for a full replay.
// TUI modes still recover the whole picture, with the downgrade guard for
// repaint-mode panes whose tmux capture can be smaller than xterm's buffer.
const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell';
let capture = await this._fetchTerminalCapture(
useFullHistory
? `/api/sessions/${sessionId}/terminal?full=1`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`,
{ full: useFullHistory }
);
let headersReceivedAt = capture.headersAt;
let data = capture.json?.data ?? {};
if (useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
headersReceivedAt = capture.headersAt;
data = capture.json?.data ?? {};
}
// Bail on a tab switch mid-fetch: writing here would paint this session's
// history into the terminal the user is now looking at. The window is two
// fetches wide in the fallback case, so this guard is not optional.
if (this.activeSessionId !== sessionId || this._terminalRefreshOwner !== refreshOwner) return false;
if (data.terminalBuffer) {
// This refresh is SERVER-triggered, so a user quietly reading scrollback
// did not ask for it and must not be dragged to the bottom by it (#259).
// The rewrite replaces the buffer, so an absolute viewportY is
// meaningless across it — distance from the bottom is what survives.
const before = this.terminal.buffer?.active;
const linesFromBottom = before ? Math.max(0, (before.baseY || 0) - (before.viewportY || 0)) : 0;
// One queued clear, not clear()+reset(): both of those are synchronous
// and skip xterm's write queue, so live bytes still parsing would land
// after them and fuse into the buffer written below. See
// _resetTerminalForReplay.
this._resetTerminalForReplay();
await this.chunkedTerminalWrite(
data.terminalBuffer,
TERMINAL_CHUNK_SIZE,
undefined,
this._bufferLoadFinishOpts(data, headersReceivedAt)
);
// A tail fetch can be partial, and the banner would otherwise keep
// describing the pre-refresh buffer (#258).
this._setHistoryTruncation(sessionId, data);
const target = computeRewriteScrollLine({
linesFromBottom,
baseY: this.terminal.buffer?.active?.baseY ?? 0,
});
if (target === null || typeof this.terminal.scrollToLine !== 'function') this.terminal.scrollToBottom();
else this.terminal.scrollToLine(target);
// The load's own replay sampled the sticky-scroll baseline while the
// terminal sat at the bottom of a just-rewritten buffer, so the next
// flush would scroll back down and undo the restore above.
this._syncStickyScrollBaseline();
// 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);
}
}
// ⚠️ HERE: after a response arrived, and NOT in the `finally`. The marker
// means "this session lost output", and only a reconcile that actually
// completed settles it. Clearing on every exit meant one that threw — or
// hit the fetch deadline, which is the flaky-link case the marker exists
// for — dropped the gap silently with nothing to retry it.
// ⚠️ Outside the `if (data.terminalBuffer)` too: a server that answers
// with an empty capture HAS reconciled us, there was simply nothing to
// replay. Leaving the marker set there refetched on every reconnect for
// the life of the page.
this._markTerminalBufferReconciled(sessionId);
return true;
} catch (err) {
console.error('needsRefresh reload failed:', err);
return err?.name === 'AbortError' ? 'deadline' : false;
} finally {
if (this._terminalRefreshOwner === refreshOwner) this._terminalRefreshOwner = null;
}
}
/**
* Drop the "this session lost output" marker.
*
* Called from every path that repaints a session's buffer from the server, so
* the ws.onopen reconcile fires once and only when nothing else already did
* the work. See the ws.onclose note for what the marker means.
*/
_markTerminalBufferReconciled(sessionId) {
if (sessionId && this._wsOutputGapSession === sessionId) this._wsOutputGapSession = null;
}
async _onSessionClearTerminal(data) {
// The tiles get the clear over their own sockets; the parked main terminal must not refetch.
if (this._tilesOwnTerminal?.()) return;
if (data.id === this.activeSessionId) {
// 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;
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
try {
// No-param capture: `terminalBufferMaxBytes` (32MB) is its only ceiling,
// so it needs the full-history budget. Defaulting to the tail budget
// gave the largest payload the smallest deadline.
const capture = await this._fetchTerminalCapture(`/api/sessions/${data.id}/terminal`, { full: true });
const headersReceivedAt = capture.headersAt;
const termData = capture.json?.data ?? {};
// Queued clear — see _resetTerminalForReplay for why clear()+reset()
// cannot do this job.
this._resetTerminalForReplay();
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,
TERMINAL_CHUNK_SIZE,
undefined,
this._bufferLoadFinishOpts(termData, headersReceivedAt)
);
}
// Fire-and-forget resize — don't block on it
this.sendResize(data.id);
// Re-position local echo overlay at new prompt location
this._localEchoOverlay?.rerender();
} catch (err) {
console.error('clearTerminal refresh failed:', err);
}
}
}
_onSessionCompletion(data) {
this.totalCost += data.cost || 0;
this.updateCost();
// Not into the parked main terminal while the tile grid owns the screen.
if (data.id === this.activeSessionId && !this._tilesOwnTerminal?.()) {
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._tilesOwnTerminal?.()) {
this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`);
}
this._notifySession(data.id, 'critical', 'session-error', 'Session Error', data.error || 'Unknown error');
}
_onSessionExit(data) {
if (this._wsSessionId === data.id) this._disconnectWs();
const session = this.sessions.get(data.id);
if (session) {
session.status = 'stopped';
this.renderSessionTabs();
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// Notify on unexpected exit (non-zero code)
if (data.code && data.code !== 0) {
this._notifySession(data.id, 'critical', 'session-crash', 'Session Crashed', `Exited with code ${data.code}`);
}
}
_onSessionIdle(data) {
const session = this.sessions.get(data.id);
if (session) {
session.status = 'idle';
this.renderSessionTabs();
this.sendPendingCtrlL(data.id);
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// Start stuck detection timer (only if no respawn running)
if (!this.respawnStatus[data.id]?.enabled) {
const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000;
clearTimeout(this.idleTimers.get(data.id));
this.idleTimers.set(data.id, setTimeout(() => {
this._notifySession(data.id, 'warning', 'session-stuck', 'Session Idle', `Idle for ${Math.round(threshold / 60000)}+ minutes`);
this.idleTimers.delete(data.id);
}, threshold));
}
}
_onSessionWorking(data) {
const session = this.sessions.get(data.id);
if (session) {
session.status = 'busy';
// Only clear tab alert if no pending hooks (permission_prompt, elicitation_dialog, etc.)
if (!this.pendingHooks.has(data.id)) {
this.tabAlerts.delete(data.id);
}
this.renderSessionTabs();
this.sendPendingCtrlL(data.id);
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// Clear stuck detection timer
const timer = this.idleTimers.get(data.id);
if (timer) {
clearTimeout(timer);
this.idleTimers.delete(data.id);
}
}
_onSessionAutoClear(data) {
if (data.sessionId === this.activeSessionId) {
this.showToast(`Auto-cleared at ${data.tokens.toLocaleString()} tokens`, 'info');
this.updateRespawnTokens(0);
}
this._notifySession(data.sessionId, 'info', 'auto-clear', 'Auto-Cleared', `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`);
}
_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);
}
// 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');
}
_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();
}
}
// Claude + Codex plan usage limits — account-global, so the latest sample
// drives the shared header chip.
_onSessionStatusTelemetry(data) {
this.updatePlanUsageChip(data);
// 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 {}
}
updatePlanUsageChip(data) {
const chip = document.getElementById('planUsageChip');
if (!chip || !data) return;
const pct = (w) => (w && typeof w.usedPercentage === 'number' ? Math.round(w.usedPercentage) : null);
// 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');
// 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.
// `idle: true` keeps a missing window's SLOT with a dimmed em dash instead of
// dropping it. Claude only: Claude Code documents `five_hour` as "present
// only while the API reports it and its resets_at has not passed", so that
// key leaves the statusline payload whenever no 5-hour session window is
// open, and a chip that silently shrank from two windows to one read as a
// broken feature rather than as an idle window (reported 2026-09-01). A
// missing CODEX bucket means the opposite — that plan has no such limit —
// so those stay omitted rather than showing a dash forever.
// Every window also carries a ring (the Compact header style) and a meter
// (Tiles). styles.css hides both in the classic style, so the chip there
// reads exactly as before. `fill` is clamped for the two graphics only; the
// label keeps the real number.
const seg = (label, p, idle) => {
if (p === null) {
if (!idle) return '';
return `<span class="pu-win pu-win-idle"><span class="pu-ring" style="--pu:0"></span><span class="pu-label">${label}</span><span class="pu-val">—</span><span class="pu-meter"><i style="width:0%"></i></span></span>`;
}
const n = Math.round(Number(p));
if (!Number.isFinite(n)) return '';
const fill = Math.min(100, Math.max(0, n));
const cls = colorClass(n);
return `<span class="pu-win"><span class="pu-ring ${cls}" style="--pu:${fill}"></span><span class="pu-label">${label}</span><span class="pu-val ${cls}">${n}%</span><span class="pu-meter ${cls}"><i style="width:${fill}%"></i></span></span>`;
};
// The provider label only earns its space when there is more than one
// provider to tell apart: a machine with Claude alone shows bare windows.
const hasWindows = (usage) => pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null;
const labelled = hasWindows(data) && hasWindows(data.codex);
const row = (provider, usage, idle) => {
// hasWindows() gates the row, so a placeholder can only ever appear
// ALONGSIDE a real reading — a provider reporting nothing still renders
// nothing, never a row of em dashes.
if (!hasWindows(usage)) return '';
const windows = [seg('5h', pct(usage?.fiveHour), idle), seg('7d', pct(usage?.sevenDay), idle)].filter(Boolean);
if (!windows.length) return '';
const label = labelled ? `<span class="pu-provider">${provider}</span>` : '';
return `<span class="pu-row">${label}<span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
};
const rows = [row('Claude', data, true), row('Codex', data.codex, false)].filter(Boolean);
chip.innerHTML = rows.length ? rows.join('') : '—';
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
const details = (provider, usage, idle) => {
const lines = [];
const five = pct(usage?.fiveHour);
const seven = pct(usage?.sevenDay);
if (five !== null) lines.push(`5-hour limit: ${five}% used (resets ${resetStr(usage.fiveHour)})`);
else if (idle && seven !== null) lines.push('5-hour limit: no active session window');
if (seven !== null) lines.push(`Weekly limit: ${seven}% used (resets ${resetStr(usage.sevenDay)})`);
return lines.length ? `${provider} plan usage\n${lines.join('\n')}` : '';
};
chip.title =
[details('Claude', data, true), details('Codex', data.codex, false)].filter(Boolean).join('\n\n') ||
'Plan usage limits';
}
// 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
// ═══════════════════════════════════════════════════════════════
setConnectionStatus(status) {
this._connectionStatus = status;
// Track when the transport left 'connected'. The connection-loss UI waits
// out a deploy-length blip before showing anything (see constants.js).
if (status === 'connected') {
this._connDownSince = null;
this._nextSseRetryAt = null;
this._offlineOverlayDismissed = false;
} else if (this._connDownSince === null) {
this._connDownSince = Date.now();
}
this._updateConnectionIndicator();
this._updateConnectionLossUi();
if (status === 'connected') {
// Reconnected (SSE) — push any durably-queued input out immediately
// instead of waiting for the next 2s sweep.
this._redeliverSweep();
}
}
// ═══════════════════════════════════════════════════════════════
// 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();
this._wsState = 'connecting';
this._updateConnectionIndicator();
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
// 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
// reconnect supersedes its own socket instead of consuming a new slot and
// 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)}` : '';
const url = `${proto}//${location.host}${CodemanBase.base}/ws/sessions/${sessionId}/terminal${cidQuery}`;
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;
this._wsState = 'connected';
this._wsReconnectAttempts = 0;
this._updateConnectionIndicator();
// 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?.(() => {});
this._startMobileResizeRetry(sessionId);
// 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);
// Reconcile the output hole this drop left (see the ws.onclose note).
// Only after an unintentional close — a first connect has no gap, and
// refetching there would duplicate the buffer selectSession just wrote.
if (this._wsOutputGapSession === sessionId) {
// NOT cleared here. `_onSessionNeedsRefresh` clears it once it has
// actually repainted; a reconcile that fails or is skipped (a buffer
// load already in flight, a tab switch) leaves the marker set so the
// next open retries. Re-entry is safe: `_terminalRefreshOwner` makes
// a second reconcile for the same session a no-op.
_crashDiag.log(`WS REOPEN: reconciling output gap for ${sessionId}`);
// Fire-and-forget: this is recovery, and a failure here must not stop
// the socket coming up. _onSessionNeedsRefresh already guards against
// running while a buffer load is in flight and against a tab switch
// landing this session's history in another session's terminal.
void this._onSessionNeedsRefresh({ id: sessionId });
}
}
};
ws.onmessage = (event) => {
if (this._ws !== ws) return;
// 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();
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 });
} 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, msg, sessionId);
} else if (msg.t === 'zc') {
// Resize confirm — the geometry the PTY actually holds, which is not
// always the one this client asked for (issue #464).
this._onPtyGeometryReport(sessionId, msg.c, msg.r);
}
} catch {
// Ignore malformed messages
}
};
ws.onclose = (event) => {
if (this._ws !== ws) return;
this._ws = null;
this._wsSessionId = null;
this._wsReady = false;
this._stopMobileResizeRetry();
// 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}`
);
// Output frames carry no sequence number, so a dropped socket leaves a
// hole with nothing to replay it. ws.onopen re-sends dims and flushes
// queued INPUT; `needsRefresh` fires only on external-CLI startup and on
// SSE backpressure drain, never here.
//
// ⚠️ The gap this closes is NARROWER than "the device went offline". If
// the network drops, SSE drops with it and `handleInit`'s keepTerminal
// branch already reconciles on reconnect. The uncovered case is the WS
// dying while SSE stays up — a half-open socket, a proxy idle-timeout,
// a ping timeout — because `_onSSETerminal` discards every SSE terminal
// frame while `_wsReady` is true, and `_wsReady` only flips here, in
// onclose. Detecting a half-open socket takes up to the ping+pong window,
// and that whole span produces output nothing writes to the terminal.
//
// Reaching onclose at all means the drop was NOT intentional
// (_disconnectWs nulls this handler first), so mark the gap and let the
// next successful open reconcile from the server's buffer.
//
// Scoped to the session that actually lost bytes: a user who switches
// sessions during an outage gets a clean intentional disconnect for the
// new one, and its freshly-loaded buffer must not be refetched because a
// DIFFERENT session's socket dropped.
this._wsOutputGapSession = sessionId;
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);
}
}
};
ws.onerror = () => {
// onclose will fire after onerror — cleanup happens there
};
}
/** Close the active WebSocket connection (if any). */
_disconnectWs() {
this._clearTimer('_wsReconnectTimer');
// 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';
this._stopMobileResizeRetry();
if (this._ws) {
this._ws.onclose = null; // Prevent re-entrant cleanup
this._ws.close();
this._ws = null;
this._wsSessionId = null;
this._wsReady = false;
}
}
/**
* 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;
}
}
/**
* 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.
*/
_sendInputAsync(sessionId, input, opts) {
if (!sessionId || !input) return;
const useMux = opts?.useMux === true;
// Both transports refuse a frame over the server's limit (issue #484), and a
// refused frame used to sit at the head of the durable queue for good. So an
// oversized paste goes out as several in-limit frames, delivered in seq order
// as one contiguous stream. A mux write is line-oriented (it strips newlines
// and sends Enter on its own), so it is never split: refuse it instead.
const limit = window.CodemanInputLimit;
if (limit && input.length > limit.FRAME_MAX_CHARS) {
if (useMux || input.length > limit.PASTE_MAX_CHARS) {
const max = useMux ? limit.FRAME_MAX_CHARS : limit.PASTE_MAX_CHARS;
this.showToast?.(
`Input too large (${Math.ceil(input.length / 1024)} KB, limit ${Math.floor(max / 1024)} KB); not sent`,
'error'
);
return;
}
for (const frame of limit.split(input)) this._reliableSend(sessionId, frame, false);
return;
}
this._reliableSend(sessionId, input, useMux);
}
/**
* The OPEN terminal socket bound to `sessionId`, or null: the primary socket
* first, then one registered by a second terminal (the split pane). The
* delivery layer below asks this instead of reading `this._ws` directly, so a
* second terminal's input gets the same exactly-once queue, ACKs and
* half-open detection as the primary's.
*
* Returns `{ ws, lastRecvAt }`: `lastRecvAt` is the socket's last received
* frame, which `_redeliverSweep` reads to tell a dead socket from a slow ACK.
*/
_inputSocketFor(sessionId) {
if (!sessionId) return null;
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
return { ws: this._ws, lastRecvAt: this._wsLastRecvAt };
}
const handle = this._extraInputSockets?.get(sessionId);
if (handle && handle.ws && handle.ws.readyState === WebSocket.OPEN) return handle;
return null;
}
/**
* Register a second terminal's socket for `sessionId` (call from its onopen).
* `handle` is `{ ws, lastRecvAt }`, owned by the caller, which must bump
* `handle.lastRecvAt` on every received frame. Stamped here so a socket that
* just opened does not look silent to the redelivery sweep, which would
* otherwise force-close it as half-open on the first stale record.
*/
_registerInputSocket(sessionId, handle) {
if (!sessionId || !handle) return;
handle.lastRecvAt = Date.now();
if (!this._extraInputSockets) this._extraInputSockets = new Map();
this._extraInputSockets.set(sessionId, handle);
}
/**
* Drop a registration, but only the one `handle` made: a replacement socket
* registers its own handle before the old one's close lands, and that late
* close must not unregister the socket that replaced it.
*/
_unregisterInputSocket(sessionId, handle) {
if (this._extraInputSockets?.get(sessionId) === handle) this._extraInputSockets.delete(sessionId);
}
/**
* 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;
const sock = this._inputSocketFor(sessionId);
if (sock) {
try {
sock.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
}
}
/** 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);
// ⚠️ SYNCHRONOUS, not the debounced writer: the seq counter is precisely the
// thing that must survive a crash, and a debounce puts it on the path most
// likely to be lost. A counter that comes back BELOW the server's watermark
// makes every later keystroke a silently-dropped duplicate (see _onWsInputAck).
this._persistReliableNow();
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).
const sock = this._inputSocketFor(sessionId);
if (sock) {
for (const rec of list) {
if (rec.sentAt !== 0) continue;
try {
sock.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
}
}
return;
}
// 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 () => {
try {
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._inputSocketFor(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 if (resp && (resp.status === 400 || resp.status === 413)) {
// The frame itself was refused, so a retry gets the same answer. Kept
// queued, it was re-POSTed every 2 s forever and blocked every later
// input for this session behind it (issue #484). 401/403 stay
// transient: an expired login delivers fine once the user signs in.
this._dropRejectedInput(sessionId, rec);
} else {
break; // offline / 5xx — leave queued; sweep + reconnect retry later
}
}
} finally {
this._postDraining.delete(sessionId);
}
})();
}
/** Drop a frame the server refused for good, and say so once. */
_dropRejectedInput(sessionId, rec) {
this._ackDelivery(sessionId, rec.seq);
this.showToast?.(`Input refused by the server (${Math.ceil(rec.data.length / 1024)} KB); not sent`, 'error');
}
/** 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();
}
}
// ⚠️ IDLE ONLY, and acknowledged server-side rather than cleared in memory.
// Delivering input answers "Claude is waiting for a prompt" by definition,
// so this is the same "I am on it" signal as opening the tab. It does NOT
// answer a permission/question dialog: those ignore any keystroke that is
// not one of their options, so the dialog is still up and still needs you.
// Clearing action alerts here hid a LIVE alert on this device alone (the
// other devices stayed red and a reload re-seeded it straight back).
this.markIdleAlertSeen?.(sessionId);
}
/**
* Server input-ACK frame ({t:'ia',seq}) over the WebSocket.
*
* `dup:true` means the server REJECTED the frame as already-seen rather than
* applying it, and `last` is its watermark for this clientId. That combination
* is the escape hatch from a rolled-back counter: our seqs persist on a
* debounced write, so a tab killed between a send and that write comes back
* counting from BELOW the server's watermark, and from then on every keystroke
* is dropped-but-ACKed — a silently dead terminal that a reload cannot fix,
* because the stale counter is restored from localStorage too.
*
* ⚠️ Only a FIRST-attempt record is re-queued. A retry (`tries > 1`) being
* called a duplicate is the mechanism working as designed — the original did
* land — and re-sending it would type the same thing twice.
*/
_onWsInputAck(seq, msg, sessionId = this._wsSessionId) {
// `sessionId` is the session of the socket the ACK arrived on: `{t:'ia'}`
// frames carry none, and with a second terminal's socket in play
// `this._wsSessionId` is no longer the only candidate.
if (!sessionId || !Number.isInteger(seq)) return;
if (msg && msg.err) {
// Refused for good (e.g. over the size limit): retrying cannot help.
const rec = (this._pendingDeliveries.get(sessionId) || []).find((r) => r.seq === seq);
if (rec) this._dropRejectedInput(sessionId, rec);
else this._ackDelivery(sessionId, seq);
return;
}
if (msg && msg.dup) {
const list = this._pendingDeliveries.get(sessionId);
const rec = list && list.find((r) => r.seq === seq);
const watermark = Number.isInteger(msg.last) ? msg.last : seq;
// Lift the counter clear of the server's watermark before anything else, so
// the re-queue below (and every later keystroke) gets an acceptable seq.
if ((this._seqCounters.get(sessionId) || 0) <= watermark) {
this._seqCounters.set(sessionId, watermark);
this._persistReliableNow();
}
const lost = rec && rec.tries <= 1 ? rec.data : null;
this._ackDelivery(sessionId, seq);
if (lost !== null) this._reliableSend(sessionId, lost, rec.useMux);
return;
}
this._ackDelivery(sessionId, 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 sock = this._inputSocketFor(sessionId);
if (sock) {
const oldest = list[0];
// 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.
// Silence is measured on THIS socket: the primary's last frame says
// nothing about a second terminal's connection, and the reverse.
const stale = oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs;
const silent = Date.now() - (sock.lastRecvAt || 0) > this._reliableAckTimeoutMs;
if (stale && silent) {
try {
sock.ws.close(); // half-open: never recovers on its own — force reconnect
} catch {
/* ignore */
}
continue;
}
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;
}
}
}
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) {
const frameMax = window.CodemanInputLimit?.FRAME_MAX_CHARS ?? Infinity;
const kept = recs
.filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq))
// A frame over the server's limit can never be ACKed; one persisted
// by an older build would otherwise come back on every load (#484).
.filter((r) => r.data.length <= frameMax)
// Reset sentAt so they re-deliver promptly on this fresh load.
.map((r) => ({
seq: r.seq,
data: r.data,
useMux: !!r.useMux,
ts: r.ts || Date.now(),
tries: 0,
sentAt: 0,
}));
if (kept.length) this._pendingDeliveries.set(s, kept);
}
}
}
} 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 */
}
}
// 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() {
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` : '';
// Hard offline (browser reports no network) dominates everything.
if (!this.isOnline || this._connectionStatus === 'offline') {
return {
display: 'flex',
dotClass: 'connection-dot offline',
text: showBacklog ? `Offline (${formatBytes(totalBytes)} queued)` : 'Offline',
title: 'No network connection',
};
}
// With an active terminal, show its transport (WebSocket vs HTTP fallback).
// While the tile grid owns the terminal the main socket is parked on
// purpose, so the state comes from the tiles' sockets: all open is
// connected, any still coming back is reconnecting.
if (this.activeSessionId) {
let cls, label, detail;
switch (this._tilesOwnTerminal?.() ? this._tileGridSocketState() : 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;
}
return {
display: 'flex',
dotClass: `connection-dot ${cls}`,
text: `${label}${queuedSuffix}`,
title: detail,
};
}
// No active terminal — reflect the SSE event stream only when it needs attention.
if (this._connectionStatus === 'reconnecting' || this._connectionStatus === 'disconnected') {
return {
display: 'flex',
dotClass: 'connection-dot reconnecting',
text: showBacklog ? `Reconnecting (${formatBytes(totalBytes)} queued)` : 'Reconnecting...',
title: 'Reconnecting to server',
};
}
// Idle dashboard, healthy stream — hide unless input is genuinely queued.
if (!hasQueue) {
return { display: 'none', dotClass: '', text: '', title: '' };
}
return {
display: 'flex',
dotClass: 'connection-dot draining',
text: showBacklog ? `Sending ${formatBytes(totalBytes)}...` : 'Sending...',
title: 'Delivering queued input',
};
}
/**
* The two words the Tiles header style shows for the connection indicator
* (label over value), derived from the descriptor rather than added to it,
* so the descriptor and its pinned strings stay exactly what they were. The
* classic text line (queued bytes and all) stays in the DOM and the full
* detail stays in the tooltip.
* @param {{dotClass: string, text: string}} desc
* @returns {{label: string, value: string, state: string}}
*/
_connectionTileWords(desc) {
const state = (desc.dotClass || '').replace('connection-dot', '').trim();
const text = desc.text || '';
switch (state) {
case 'connected':
return { label: 'WS', value: 'live', state };
case 'fallback':
return { label: 'HTTP', value: 'fallback', state };
case 'offline':
return { label: 'NET', value: 'offline', state };
case 'draining':
return { label: 'SEND', value: 'queued', state };
case 'reconnecting':
// The same dot covers the terminal WebSocket and, with no session
// open, the SSE event stream; the classic text already tells them apart.
return { label: text.startsWith('WS') ? 'WS' : 'SSE', value: 'retry', state };
default:
return { label: '', value: '', state };
}
}
/**
* The tile's value word as shown: i18n.js's scoped 'Connection tile: <word>'
* entry in Chinese, the English word otherwise. Never a bare-word key: those
* would also translate other text ("retry" is the orchestrator's Retry button),
* which is why the value span carries data-i18n-skip.
* @param {string} value
* @returns {string}
*/
_connectionTileValueText(value) {
if (!value) return '';
const key = `Connection tile: ${value}`;
const translated = typeof window.codemanT === 'function' ? window.codemanT(key) : key;
return translated && translated !== key ? translated : value;
}
_updateConnectionIndicator() {
const indicator = this.$('connectionIndicator');
const dot = this.$('connectionDot');
const text = this.$('connectionText');
if (!indicator || !dot || !text) return;
// 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;
// The tile's value word is written in the UI language, so a language
// switch counts as a change too.
const language = window.CodemanI18n?.language || 'en';
if (
prev &&
language === this._lastIndicatorLanguage &&
prev.display === next.display &&
prev.dotClass === next.dotClass &&
prev.text === next.text &&
prev.title === next.title
) {
return;
}
this._lastIndicatorDescriptor = next;
this._lastIndicatorLanguage = language;
indicator.style.display = next.display;
if (next.display !== 'none') {
dot.className = next.dotClass;
text.textContent = next.text;
indicator.title = next.title;
const tileLabel = this.$('connectionTileLabel');
const tileValue = this.$('connectionTileValue');
if (tileLabel && tileValue) {
const words = this._connectionTileWords(next);
tileLabel.textContent = words.label;
tileValue.textContent = this._connectionTileValueText(words.value);
tileValue.className = `connection-tile-value ${words.state}`.trim();
}
}
}
setupOnlineDetection() {
window.addEventListener('online', () => {
this.isOnline = true;
this.reconnectAttempts = 0;
// Restart the grace window: the radio just came back, so the next couple
// of seconds of "not connected" are expected, not a server problem.
this._connDownSince = Date.now();
this.connectSSE();
// Network came back — drain durably-queued input right away.
this._redeliverSweep();
});
window.addEventListener('offline', () => {
this.isOnline = false;
this.setConnectionStatus('offline');
});
}
// ── Connection-loss UI ─────────────────────────────────────────────────────
// Why this exists: the service worker serves the cached app shell, so opening
// Codeman with the server unreachable (phone off the tailnet, VPN down,
// server stopped) rendered a normal-looking but empty dashboard whose only
// hint was an 8px red dot in the header corner. The decision of what to show
// is pure (computeConnectionLossUi in constants.js); this is the writer.
/** Apply the offline banner / overlay for the current connection state. */
_updateConnectionLossUi() {
const policy = window.CodemanConnectionLoss;
const banner = this.$('offlineBanner');
const overlay = this.$('offlineOverlay');
if (!policy || !banner || !overlay) return;
const state = policy.compute({
isOnline: this.isOnline,
status: this._connectionStatus,
// Server state has landed at least once this page load (SSE `init`), so
// there is a UI worth keeping visible behind a non-blocking banner.
everLoaded: this._initGeneration > 0,
downSince: this._connDownSince,
now: Date.now(),
nextRetryAt: this._nextSseRetryAt,
overlayDismissed: this._offlineOverlayDismissed,
retryPending: this._offlineRetryPending,
});
// The ticker drives both the countdown and the grace deadline; neither is
// event-driven, so it must run whenever the transport is down, including
// while the decision is still 'hidden' inside the grace window.
if (this._connDownSince === null) this._stopOfflineTicker();
else this._startOfflineTicker();
const retryLabel = this._offlineRetryPending
? 'Reconnecting…'
: state.retryInSec != null && state.retryInSec > 0
? `Retrying in ${state.retryInSec}s`
: 'Retrying…';
// Called every second by the ticker, so skip the DOM writes when the rendered
// result is unchanged (same reasoning as _updateConnectionIndicator).
const key = `${state.mode}|${state.kind}|${retryLabel}`;
if (key === this._lastOfflineUiKey) return;
this._lastOfflineUiKey = key;
banner.hidden = state.mode !== 'banner';
overlay.hidden = state.mode !== 'overlay';
document.body.classList.toggle('connection-lost', state.mode !== 'hidden');
if (state.mode === 'banner') {
const text = this.$('offlineBannerText');
const detail = this.$('offlineBannerDetail');
if (text) text.textContent = state.title;
if (detail) detail.textContent = retryLabel;
} else if (state.mode === 'overlay') {
const title = this.$('offlineOverlayTitle');
const body = this.$('offlineOverlayBody');
const host = this.$('offlineOverlayHost');
const status = this.$('offlineOverlayStatus');
if (title) title.textContent = state.title;
if (body) body.textContent = state.detail;
if (host) host.textContent = location.host;
if (status) status.textContent = retryLabel;
}
}
_startOfflineTicker() {
if (this._offlineUiTicker) return;
this._offlineUiTicker = setInterval(() => this._updateConnectionLossUi(), 1000);
}
_stopOfflineTicker() {
if (!this._offlineUiTicker) return;
clearInterval(this._offlineUiTicker);
this._offlineUiTicker = null;
}
/** Retry button on the banner/overlay: reconnect now instead of waiting out
* the backoff (capped at 30s, and the WS plan can give up entirely). */
retryConnection() {
this._offlineRetryPending = true;
this._nextSseRetryAt = null;
this.reconnectAttempts = 0;
this._clearTimer('sseReconnectTimeout');
this.isOnline = navigator.onLine;
this._lastOfflineUiKey = '';
this._updateConnectionLossUi();
this.connectSSE();
// The terminal socket does not always come back on its own (planWsReconnect
// 'give-up'), so the same button re-arms it. With the tile grid open the
// main socket is parked on purpose: re-arm the tiles' sockets instead.
if (this._tilesOwnTerminal?.()) {
for (const { tile } of this._tileGrid.tiles.values()) tile.reconnectNow();
} else if (this.activeSessionId && this._wsState !== 'connected') {
this._wsReconnectAttempts = 0;
this._connectWs(this.activeSessionId);
}
this._clearTimer('_offlineRetryTimer');
this._offlineRetryTimer = setTimeout(() => {
this._offlineRetryPending = false;
this._lastOfflineUiKey = '';
this._updateConnectionLossUi();
}, 1500);
}
/** "Show cached view": demote the blocking overlay to the banner for the rest
* of this outage, so the cached UI can be inspected offline. */
dismissOfflineOverlay() {
this._offlineOverlayDismissed = true;
this._lastOfflineUiKey = '';
this._updateConnectionLossUi();
}
/** Show/hide the CJK input textarea based on user setting or server override */
_updateCjkInputState() {
const cjkEl = document.getElementById('cjkInput');
if (!cjkEl) return;
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings?.() || {};
// 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.
// 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;
cjkEl.classList.toggle('cjk-input-visible', !!showCjk);
document.body.classList.toggle('cjk-input-visible', !!showCjk);
cjkEl.style.display = showCjk ? 'block' : 'none';
cjkEl.setAttribute('aria-hidden', showCjk ? 'false' : 'true');
if (!showCjk) window.cjkActive = false;
if (typeof KeyboardHandler !== 'undefined') KeyboardHandler.updateLayoutForKeyboard();
}
/**
* 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.
*
* @param {boolean} [preserveTerminal] Keep the terminal caches. Set when an SSE
* RECONNECT lands back on the session already on screen: the buffers still
* describe that session, and dropping them forces a full refetch + xterm
* reset that throws away the user's scroll position (see handleInit).
*/
_resetAllAppState(preserveTerminal = false) {
this.sessions.clear();
this.ralphStates.clear();
if (!preserveTerminal) {
this.terminalBuffers.clear();
this.terminalBufferCache.clear();
this._xtermSnapshots?.clear();
}
this.projectInsights.clear();
this.teams.clear();
this.teamTasks.clear();
// Clear all idle timers to prevent stale timers from firing
for (const timer of this.idleTimers.values()) {
clearTimeout(timer);
}
this.idleTimers.clear();
// Clear flicker filter state
this._clearTimer('flickerFilterTimeout');
this.flickerFilterBuffer = '';
this.flickerFilterActive = false;
// Clear pending terminal writes
this._clearTimer('syncWaitTimeout');
this._clearTimer('_clientDropRecoveryTimer');
this.pendingWrites = [];
this.writeFrameScheduled = false;
// Release the one-chunk-in-flight gate with the rest of the write queue.
// flushPendingWrites() early-returns while this is set, so a reset that
// cleared everything EXCEPT this flag would leave live output permanently
// stalled if xterm's parse callback never lands (disposed terminal, or a
// throw inside the async parse). A late callback is harmless: it clears an
// already-clear flag and schedules a flush.
this._terminalWriteInFlight = false;
this._terminalWriteInFlightBytes = 0;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._bufferLoadOwner = null;
this._terminalRefreshOwner = null;
// Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
// 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();
// Deliberate asymmetry: buffer-mode pending text SURVIVES reconnect (not
// yet sent); predictions do not (their keystrokes were already delivered).
this._predictiveEcho?.clearPredictions();
// Clear pending hooks
this.pendingHooks.clear();
// Clear approvals (re-seeded from GET /api/approvals right after init)
this.approvals?.clear();
// Clear parent name cache (prevents stale session name entries accumulating)
if (this._parentNameCache) this._parentNameCache.clear();
// Clear subagent activity/results maps (prevents leaks if data.subagents is missing)
this.subagentActivity.clear();
this.subagentToolResults.clear();
// 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;
// Clean up mobile/keyboard handlers and re-init (prevents listener accumulation on reconnect)
MobileDetection.cleanup();
KeyboardHandler.cleanup();
MobileDetection.init();
KeyboardHandler.init();
// Clear tab alerts
this.tabAlerts.clear();
this.attachmentHistoryCounts.clear();
// Clear shown completions (used for duplicate notification prevention)
if (this._shownCompletions) {
this._shownCompletions.clear();
}
// Clear notification manager title flash interval to prevent memory leak
if (this.notificationManager?.titleFlashInterval) {
clearInterval(this.notificationManager.titleFlashInterval);
this.notificationManager.titleFlashInterval = null;
}
// Clear notification manager grouping timeouts (prevents orphaned timers)
if (this.notificationManager?.groupingMap) {
for (const { timeout } of this.notificationManager.groupingMap.values()) {
clearTimeout(timeout);
}
this.notificationManager.groupingMap.clear();
}
// ⚠️ The terminal resize observer is NOT reset here. initTerminal() owns its
// lifecycle (it runs once per page and disconnects any previous observer
// before creating one), and this reset runs on EVERY SSE init, page load
// included. It used to disconnect the observer "to prevent a leak", which
// left the terminal with no observer from the first init on: only a WINDOW
// resize ever refit it, so anything that resized just the terminal box
// (state rows and lineage room appearing in the header, the tab strip
// wrapping) clipped xterm's bottom rows behind the toolbar until a tab
// switch or a window resize.
// 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;
}
}
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();
// 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);
// A board left open across a host reboot reconnects HERE, to a server that came
// back with an empty session list. The reboot-restore offer is built at boot,
// before any client could be listening, so re-read it on every init rather than
// only on the page-load path.
this.refreshRebootRestoreBanner?.();
// 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();
// A RECONNECT that lands back on the same session must not become a full
// reload. This used to clear the terminal caches and re-run selectSession()
// unconditionally, so every SSE reconnect refetched the buffer (up to 1 MiB)
// and reset+rewrote xterm. On a link that drops a connection about once a
// minute that reads as the page refreshing itself and losing your place.
// Keep the caches and the active id here; the restore block below resyncs
// through _onSessionNeedsRefresh(), which still reloads the buffer (so
// output produced during the outage is not lost) but preserves the reading
// position.
const activeBefore = this.activeSessionId;
const keepTerminal =
gen > 1 &&
!!activeBefore &&
Array.isArray(data.sessions) &&
data.sessions.some((s) => s.id === activeBefore);
this._resetAllAppState(keepTerminal);
data.sessions.forEach(s => {
this.sessions.set(s.id, s);
// Load ralph state from session data (only if not explicitly closed by user)
if ((s.ralphLoop || s.ralphTodos) && !this.ralphClosedSessions.has(s.id)) {
this.ralphStates.set(s.id, {
loop: s.ralphLoop || null,
todos: s.ralphTodos || []
});
}
});
// 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 {}
// 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 {}
}
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
this.syncSessionOrder();
// (Re)read the owner tab layout on every init, including SSE reconnects: a
// tab:layoutChanged sent while this client was disconnected is never replayed.
this._loadTabLayout();
if (data.respawnStatus) {
this.respawnStatus = data.respawnStatus;
} else {
// Clear respawn status on init if not provided (prevents stale data)
this.respawnStatus = {};
}
// Clean up respawn state for sessions that no longer exist
this.respawnTimers = {};
this.respawnCountdownTimers = {};
this.respawnActionLogs = {};
// Store global stats for aggregate tracking
if (data.globalStats) {
this.globalStats = data.globalStats;
}
this.totalCost = data.sessions.reduce((sum, s) => sum + (s.totalCost || 0), 0);
this.totalCost += data.scheduledRuns.reduce((sum, r) => sum + (r.totalCost || 0), 0);
const activeRun = data.scheduledRuns.find(r => r.status === 'running');
if (activeRun) {
this.currentRun = activeRun;
this.showTimer();
}
this.updateCost();
this.renderSessionTabs();
// Approvals Inbox: re-seed pending prompts from the server so alerts
// survive reloads and SSE reconnects (methods in approvals-ui.js).
this.seedApprovals?.();
// Start/stop system stats polling based on session count
if (this.sessions.size > 0) {
this.startSystemStatsPolling();
} else {
this.stopSystemStatsPolling();
}
// CRITICAL: Clean up all floating windows before loading new subagents
// This prevents memory leaks from ResizeObservers, EventSources, and DOM elements
this.cleanupAllFloatingWindows();
// Load subagents - clear all related maps to prevent memory leaks on reconnect
if (data.subagents) {
this.subagents.clear();
this.subagentActivity.clear();
this.subagentToolResults.clear();
data.subagents.forEach(s => {
this.subagents.set(s.agentId, s);
});
this.renderSubagentPanel();
// Load PERSISTENT parent associations FIRST, before restoring windows
// This ensures connection lines are drawn to the correct tabs
// Clear the in-memory map first to ensure fresh state from storage
this.subagentParentMap.clear();
this.loadSubagentParentMap().then(() => {
// Apply stored parent associations to agents
for (const [agentId, sessionId] of this.subagentParentMap) {
const agent = this.subagents.get(agentId);
if (agent && this.sessions.has(sessionId)) {
agent.parentSessionId = sessionId;
const session = this.sessions.get(sessionId);
if (session) {
agent.parentSessionName = this.getSessionName(session);
}
this.subagents.set(agentId, agent);
}
}
// Now try to find parents for any agents that don't have one yet
for (const [agentId] of this.subagents) {
if (!this.subagentParentMap.has(agentId)) {
this.findParentSessionForSubagent(agentId);
}
}
// Finally, restore window states (this opens windows with correct parent info)
this.restoreSubagentWindowStates();
});
}
// Seed ultracode workflow runs (LEFT-pane summaries) from the snapshot
if (data.workflowRuns) {
this.seedWorkflowRuns(data.workflowRuns);
}
// Restore previously active session (survives page reload + SSE reconnect)
// Must always re-select because handleInit clears terminal state above.
// Reset activeSessionId so selectSession doesn't early-return.
// Guard: skip if a newer handleInit has already started (race between loadState + SSE init).
if (gen !== this._initGeneration) return;
// Solo (detached) window: always show exactly the target session, ignoring
// the dashboard's "restore last active" logic.
if (this.isSoloWindow) {
this._applySoloMode();
return;
}
// Tile grid open (tile-grid.js): tiles whose sessions are gone are removed,
// the live ones are kept as they are and reconnect now, and the main
// terminal stays parked. Before the link below, which may leave the grid.
const tilesOpen = this._reconcileTileGrid?.() === true;
// A `#session=<id>` link wins over restoring the last active tab.
if (this._urlSessionId && this.sessions.has(this._urlSessionId)) {
// And over a tile grid stored open: it stays remembered, closed, rather
// than reappearing unexplained on the next reload.
if (!tilesOpen) this._closeStoredTileGrid?.();
this.activeSessionId = null;
this._selectUrlSession();
return;
}
// Not listed yet: its wait starts now that the list has loaded, and the
// last active tab is restored meanwhile.
if (this._urlSessionId) this._armUrlSessionWait(this._urlSessionId);
// The grid holds the focused session: nothing below may reconnect or reload
// the parked main terminal (its keepTerminal branch would reopen the main
// socket onto a session a tile already shows).
if (tilesOpen) return;
const previousActiveId = this.activeSessionId;
if (this.sessionOrder.length === 0) {
this.activeSessionId = null;
} else {
// Priority: current active > localStorage > first session
let restoreId = previousActiveId;
if (!restoreId || !this.sessions.has(restoreId)) {
try { restoreId = localStorage.getItem('codeman-active-session'); } catch {}
}
if (keepTerminal && restoreId === previousActiveId && this.sessions.has(restoreId)) {
// Reconnect onto the session already on screen. renderSessionTabs() ran
// above and activeSessionId never changed, so the tab strip is already
// correct; only the buffer needs to catch up. The WS has its own
// backoff reconnect, but if it is not on this session (dead socket, or
// a give-up) nothing else would re-establish it from here.
if (this._wsSessionId !== restoreId) this._connectWs(restoreId);
void this._onSessionNeedsRefresh({ id: restoreId });
// The split pane's second terminal reconnects on its own backoff (up to
// 10 s between tries); the server is back now, so skip the wait. A no-op
// while its socket is open, stopped for good, or destroyed.
this._splitPane?.reconnectNow?.();
} else {
this.activeSessionId = null;
// A tile grid stored open on this device (tile-grid.js) comes back IN
// PLACE of the single-view restore below, so the main terminal never
// loads (its first select would pull a whole-history capture only to
// be parked a moment later).
if (this._restoreTileGrid?.()) return;
// `auto`: the app is restoring a session on load, not a human opening
// one, so a pending idle alert on that tab stays armed until it is
// actually tapped (see the userInitiated note in selectSession).
if (restoreId && this.sessions.has(restoreId)) {
this.selectSession(restoreId, { auto: true });
} else {
this.selectSession(this.sessionOrder[0], { auto: true });
}
}
}
}
async loadState() {
try {
const res = await fetch('/api/status');
const data = await res.json();
this.handleInit(data?.data ?? {});
} catch (err) {
console.error('Failed to load state:', err);
}
}
// ═══════════════════════════════════════════════════════════════
// 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);
}
// ═══════════════════════════════════════════════════════════════
// Session List Layout (header strip ⟷ collapsible left sidebar)
// ═══════════════════════════════════════════════════════════════
/**
* 'header' | 'sidebar' | 'sidebar-rich'. Solo (detached single-session) windows
* are ALWAYS 'header': they show exactly one session, so a session list is
* noise — and #sessionTabs must never be parked inside the display:none
* <aside>, where updateTabOverflowMode() would measure 0/0 and the inline
* rename input would get zero geometry.
*
* The two sidebar values are the SAME layout — same docked column, same
* re-parented #sessionTabs, same filter box, same Alt+B toggle. They differ
* only in how much each row says, which is why the split rides on a separate
* attribute (see applySessionListLayout) instead of a third data-session-list
* value: every one of the ~25 isSessionSidebarActive() call sites, and every
* html[data-session-list="sidebar"] rule in styles.css and mobile.css, must
* keep matching both without being touched.
*/
getSessionListLayout() {
if (this.soloSessionId) return 'header';
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const layout = settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
return layout === 'sidebar' || layout === 'sidebar-rich' ? layout : 'header';
}
resolveSessionSidebarFontSize(value) {
const size = Number(value);
// Default 12, matching the sidebar's historical 0.75rem name size: a user
// who never touches the slider must not get silently restyled (14 here
// bumped every existing sidebar install on the rail feature's release).
return Number.isInteger(size) && size >= 11 && size <= 18 ? size : 12;
}
applySessionSidebarFontSize(settings = null) {
const resolvedSettings = settings ?? this.loadAppSettingsFromStorage();
const size = this.resolveSessionSidebarFontSize(resolvedSettings?.sessionSidebarFontSize);
document.documentElement.style.setProperty('--session-sidebar-name-font-size', `${size}px`);
return size;
}
/**
* Reads the APPLIED layout off <html>, not the settings blob: this is called
* per dragover event and per tab in render loops, and getSessionListLayout()
* re-parses localStorage on every call. The attribute is written by the
* pre-paint script in index.html and thereafter only by applySessionListLayout(),
* so it is authoritative from the very first frame.
*/
isSessionSidebarActive() {
return document.documentElement.dataset.sessionList === 'sidebar';
}
_tabOrientation() {
return document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
}
/**
* True when the session list renders as a vertical column: the sidebar layout
* OR the vertical tab rail. Axis decisions (drag insertion side, active-tab
* scroll-into-view, floating-window anchors) must use THIS, not
* isSessionSidebarActive() alone — the rail leaves data-session-list at
* 'header', so the sidebar predicate reads a vertical rail as horizontal.
*/
_isVerticalTabList() {
return this.isSessionSidebarActive() || this._tabOrientation() === 'vertical';
}
shouldInlineSessionActions() {
if (this.isSessionSidebarActive()) return !this.isSessionSidebarCollapsed();
return this._tabOrientation() === 'vertical' && !document.documentElement.classList.contains('tab-rail-compact');
}
/**
* True when the sidebar is showing the DETAILED rows: the home screen's
* per-session line ("created 3d ago · working 12m") plus a status pill.
*
* Read off <html> for the same reason as isSessionSidebarActive() — it is
* called once per tab in the render loop, and getSessionListLayout()
* re-parses localStorage on every call. Implies isSessionSidebarActive():
* data-sidebar-detail is only ever 'rich' while data-session-list is
* 'sidebar', both in applySessionListLayout() and in the pre-paint script.
*/
isSessionSidebarRich() {
const root = document.documentElement;
return root.dataset.sessionList === 'sidebar' && root.dataset.sidebarDetail === 'rich';
}
/**
* True when the VERTICAL TAB RAIL (tabOrientation 'vertical') is showing the
* detailed rows: the same "created 3d ago · working 12m" line and status pill
* the rich sidebar and both home screens carry.
*
* A docked column is not a tab strip — that was the argument for the rich
* sidebar, and the rail is a docked column too, so it defaults to rich and
* `tabRailDetail: 'simple'` is the opt-out.
*
* The compact carve-out is not cosmetic: below 240px the rail already drops
* the row actions to a hover affordance, and three lines of stamps in a
* ~208px column ellipsize into noise. `_setTabRailWidth()` re-renders the
* tabs whenever that class flips, so this gate is re-read at the right moment.
*/
isTabRailRich() {
const root = document.documentElement;
return (
root.getAttribute('data-tab-orientation') === 'vertical' &&
root.dataset.tabRailDetail === 'rich' &&
!root.classList.contains('tab-rail-compact')
);
}
/**
* The one gate the render paths ask: does THIS list draw detailed rows?
* Either vertical surface can, and neither can be on at once (the sidebar
* owns the tabs whenever it is active, which forces the rail off).
*/
isRichTabRows() {
return this.isSessionSidebarRich() || this.isTabRailRich();
}
/**
* True when the VERTICAL TAB RAIL orders its cards the way both home screens
* do — blocked on you first, then running longest-first, then quiet
* most-recently-quiet first (`CodemanSessionOrder`, constants.js) — instead of
* leaving them in the user's tab order.
*
* Read off <html> like the other two rail gates, because the render loop asks
* it once per pass and getSessionListLayout() re-parses localStorage.
* `tabRailSort: 'manual'` is the opt-out, and it is what a user who reorders
* by hand wants: a self-sorting list cannot also be drag-reorderable, so
* setupTabDragHandlers() drops the drag affordance while this is on rather
* than letting a card snap back to where the sort puts it.
*
* Deliberately NOT gated on `isTabRailRich()`: a simple rail lists the same
* sessions and answers the same question, it just says less about each one.
*/
isTabRailSorted() {
const root = document.documentElement;
return root.getAttribute('data-tab-orientation') === 'vertical' && root.dataset.tabRailSort === 'activity';
}
/**
* Visual position per session id for the sorted rail, or null when the rail is
* not sorting.
*
* The sort is applied as the flex `order` property, NOT by reordering the DOM.
* That is the whole design: `#sessionTabs` stays in `sessionOrder`, so
* drag-and-drop, the Alt+N badges, the arrow-key walk, the sidebar filter and
* `_scrollActiveTabIntoView()` all keep reading the list they have always
* read, and a session changing state moves one inline style instead of
* forcing the full rebuild that would restart every card's animation.
*
* Rows are classified by `_mobileOverviewState()` and compared by
* `CodemanSessionOrder` — the same two helpers both home screens use, so the
* rail cannot disagree with them about what "working" means or what sorts
* first. `orderIndex` is the tab-strip position, which the comparator uses as
* its deterministic final tiebreak.
*
* Guarded like every other cross-file consumer: a stale cached constants.js or
* mobile-overview.js degrades to tab order rather than taking the strip down.
*
* @param {Array<string>} ids live session ids, in tab order
* @returns {Map<string, number>|null}
*/
_tabRailSortOrder(ids) {
if (!this.isTabRailSorted()) return null;
if (!window.CodemanSessionOrder || typeof this._mobileOverviewState !== 'function') return null;
const rows = [];
for (let i = 0; i < ids.length; i++) {
const session = this.sessions.get(ids[i]);
if (!session) continue;
rows.push({
id: ids[i],
state: this._mobileOverviewState(session, this.pendingHooks?.get(ids[i])),
lastActivityAt: Number(session.lastActivityAt) || 0,
lastSubmitAt: Number(session.lastSubmitAt) || 0,
orderIndex: i,
});
}
const sorted = window.CodemanSessionOrder.sort(rows);
const out = new Map();
for (let i = 0; i < sorted.length; i++) out.set(sorted[i].id, i);
return out;
}
/**
* True when the tab list groups by state (`tabArrangement: 'state'`, opt-in;
* Discussion #426 option C): a row per state in the header strip, a section
* per state in the flat side rail and the sidebar, most urgent on top.
*
* Read off <html> like the rail gates (applyTabOrientation() owns the
* attribute). Named groups in the vertical rail still win, because they are
* the user's own structure: `_tabTriageLayout()` returns null while the
* grouped projection is on, and the grouped tree renders as it always did.
*/
isTabTriage() {
return document.documentElement.dataset.tabArrangement === 'state';
}
/**
* Order values and visible groups for one render pass, or null when the list
* is not grouped by state. The pure core is `CodemanTabTriage.layout()`
* (constants.js); this only feeds it the same classification both home
* screens and the sorted rail use.
*
* Inside a group a row keeps its tab order on the header strip, so the strip
* only moves a tab when its state changes. A sorted rail ranks rows inside
* each section the way it ranks the whole flat rail (`railSortOrder`).
*
* @param {Array<string>} ids live session ids, in tab order
* @param {object|null} groupProjection the grouped rail's projection, if any
* @param {Map<string, number>|null} railSortOrder `_tabRailSortOrder(ids)`
*/
_tabTriageLayout(ids, groupProjection, railSortOrder) {
if (groupProjection || !this.isTabTriage()) return null;
if (!window.CodemanTabTriage || typeof this._mobileOverviewState !== 'function') return null;
const rows = [];
for (let i = 0; i < ids.length; i++) {
const session = this.sessions.get(ids[i]);
if (!session) continue;
const state = this._mobileOverviewState(session, this.pendingHooks?.get(ids[i]));
rows.push({
id: ids[i],
state,
exited: !!this._mobileOverviewExit?.(state, session),
pos: railSortOrder?.has(ids[i]) ? railSortOrder.get(ids[i]) : i,
});
}
const webviewIds = (this.webviewOrder || []).filter((wid) => this.webviews?.has(wid));
const reverse = document.documentElement.dataset.tabStateOrder === 'urgent-last';
return window.CodemanTabTriage.layout(rows, webviewIds, { reverse });
}
/**
* Keep the state headings, the row breaks and the web tabs' `order` in step
* with one pass's triage layout. Runs after BOTH render paths, because a
* session changing state is an incremental pass (no tab is added or removed)
* and can still empty a group or fill a new one.
*
* Headings and breaks are keyed by group and reconciled in place, never
* rebuilt, so an SSE tick that changes nothing writes nothing. They are
* direct children of #sessionTabs placed purely by `order`, so where they sit
* in the DOM does not matter, and `aria-hidden` keeps them out of the tablist,
* whose children must all be tabs. (A tab's state is not announced either way:
* its status dot is aria-hidden, as before.) With `triage` null this removes
* them all and clears the web tabs' inline order, which is what leaves the
* ungrouped strip exactly as it was.
*/
_syncTabTriageChrome(container, triage) {
if (!container) return;
this._lastTabTriage = triage;
container.classList.toggle('tabs-triage', !!triage);
const wanted = new Map((triage?.groups || []).map((group) => [group.key, group]));
for (const el of [...container.querySelectorAll(':scope > .tab-triage-head, :scope > .tab-triage-break')]) {
if (!wanted.has(el.dataset.triageGroup)) el.remove();
}
const ensure = (className, key) => {
let el = container.querySelector(`:scope > .${className}[data-triage-group="${key}"]`);
if (!el) {
el = document.createElement('div');
el.className = className === 'tab-triage-head' ? `tab-triage-head tab-triage-head--${key}` : className;
el.dataset.triageGroup = key;
el.setAttribute('aria-hidden', 'true');
container.appendChild(el);
}
return el;
};
for (const group of wanted.values()) {
const head = ensure('tab-triage-head', group.key);
// A quiet group (idle) keeps its heading as the row's anchor but draws
// no label or count.
head.classList.toggle('tab-triage-head--quiet', !!group.quiet);
if (!group.quiet && !head.firstElementChild) {
const label = document.createElement('span');
label.className = 'tab-triage-label';
label.textContent = group.label;
const count = document.createElement('span');
count.className = 'tab-triage-count';
head.append(label, count);
}
const count = String(group.count);
if (!group.quiet && head.lastElementChild.textContent !== count) head.lastElementChild.textContent = count;
// The first row's heading is the one that starts beside the brand in the
// header strip (styles.css); every later row starts under it.
head.classList.toggle('tab-triage-head--lead', group === triage.groups[0]);
const headOrder = String(group.headOrder);
if (head.style.order !== headOrder) head.style.order = headOrder;
const brk = ensure('tab-triage-break', group.key);
const breakOrder = String(group.breakOrder);
if (brk.style.order !== breakOrder) brk.style.order = breakOrder;
}
for (const web of container.querySelectorAll(':scope > .session-tab[data-webview-id]')) {
const wid = web.dataset.webviewId;
const value = triage?.webOrder.has(wid) ? String(triage.webOrder.get(wid)) : '';
if (web.style.order !== value) web.style.order = value;
}
this._sizeTabTriageGutter(container, triage);
}
/**
* Size the header strip's two measured lengths (styles.css, "Header strip,
* wrapping"): `--tab-triage-gutter`, the label column, as wide as the widest
* label on screen so a row never carries a fixed gutter's worth of empty
* space; and `--tab-triage-brand`, the brand's width, because the brand sits
* over the strip's top-left corner and only the FIRST row starts beside it,
* every later row starting under it.
*
* The labels are measured only when their text changes (a group appears,
* goes, or its count gains a digit), when the strip starts wrapping, and
* once more when the web fonts finish loading. Only the WRAPPING strip reads
* the label column, so the phone and tablet row (headings hidden under
* 600px, inline dividers above) is never measured and keeps no measurement:
* a phone turned to landscape or a foldable opened crosses into the wrapping
* strip with no tab render behind it, and updateTabOverflowMode(), which the
* resize handler calls, sizes it right after deciding to wrap. The brand is
* watched by a ResizeObserver (a display-name change, the sidebar toggle
* appearing), so a render pass never forces a layout read for it. The
* vertical lists use neither length and are never measured.
*/
_sizeTabTriageGutter(container, triage) {
const inHeader = !!container.parentElement?.classList.contains('session-tabs-host');
if (!triage || !inHeader) {
if (container.style.getPropertyValue('--tab-triage-gutter')) container.style.removeProperty('--tab-triage-gutter');
if (container.style.getPropertyValue('--tab-triage-brand')) container.style.removeProperty('--tab-triage-brand');
this._tabTriageGutterKey = null;
return;
}
this._watchTabTriageBrand(container);
if (Number.isFinite(this._tabTriageBrandWidth)) {
const brand = `${this._tabTriageBrandWidth}px`;
if (container.style.getPropertyValue('--tab-triage-brand') !== brand) {
container.style.setProperty('--tab-triage-brand', brand);
}
}
// Not wrapping: forget the measurement, so wrapping again measures afresh.
if (!container.classList.contains('tabs-auto-wrap') && !container.classList.contains('tabs-two-rows')) {
this._tabTriageGutterKey = null;
return;
}
const key = triage.groups.map((g) => `${g.key}:${g.count}`).join('|');
if (key === this._tabTriageGutterKey) return;
let widest = 0;
for (const head of container.querySelectorAll(':scope > .tab-triage-head')) {
// Laid-out parts only. A hidden heading measures 0 per part, and counting
// the 5px gap between its parts anyway turned that into a 15px column
// that was then cached as if measured.
let width = 0;
let parts = 0;
for (const part of head.children) {
const partWidth = part.getBoundingClientRect().width;
if (partWidth > 0) {
width += partWidth;
parts++;
}
}
if (parts) widest = Math.max(widest, width + 5 * (parts - 1));
}
// Hidden (display: none, or a detached strip): nothing to size, and no key
// either, so the next pass measures again.
if (!widest) return;
this._tabTriageGutterKey = key;
container.style.setProperty('--tab-triage-gutter', `${Math.ceil(widest + 10)}px`);
if (!this._tabTriageFontsHooked && document.fonts?.ready) {
this._tabTriageFontsHooked = true;
document.fonts.ready.then(() => {
this._tabTriageGutterKey = null;
this._sizeTabTriageGutter(this.$('sessionTabs'), this._lastTabTriage);
});
}
}
/**
* Keep `_tabTriageBrandWidth` (the header brand plus the gap after it) in
* step with the brand, once per page. The first observation arrives right
* after `observe()`, so the width is known from the first frame on.
*/
_watchTabTriageBrand(container) {
if (this._tabTriageBrandObserver !== undefined) return;
const brand = container.closest('.header')?.querySelector(':scope > .header-brand');
if (!brand || typeof ResizeObserver !== 'function') {
this._tabTriageBrandObserver = null;
return;
}
const gap = 8;
this._tabTriageBrandWidth = Math.ceil(brand.getBoundingClientRect().width + gap);
this._tabTriageBrandObserver = new ResizeObserver((entries) => {
const box = entries[0]?.borderBoxSize?.[0];
const width = Math.ceil((box ? box.inlineSize : brand.getBoundingClientRect().width) + gap);
if (width === this._tabTriageBrandWidth) return;
this._tabTriageBrandWidth = width;
this._sizeTabTriageGutter(this.$('sessionTabs'), this._lastTabTriage);
});
this._tabTriageBrandObserver.observe(brand);
}
/**
* A drag in a grouped strip (by state or by case) may only reorder WITHIN a
* group. Inside a group the rows sit in tab order, so a drop there moves the
* tab exactly where it was dropped; across groups the dragged tab would stay
* in its own group (its state or case did not change) and land somewhere the
* user did not put it. State groups are bands of `order` values, so comparing
* bands is enough; case clusters are boxes, so the box decides.
*/
_isTabDropAcrossGroups(targetTab) {
const container = this.$('sessionTabs');
if (!container || !this.draggedTabId || !targetTab) return false;
const triage = container.classList.contains('tabs-triage');
const clusters = container.classList.contains('tabs-clusters');
if (!triage && !clusters) return false;
const dragged = container.querySelector(`.session-tab[data-id="${this.draggedTabId}"]`);
if (!dragged) return false;
// Clusters are real boxes: a drop belongs to the box it lands in.
if (clusters) return dragged.closest('.tab-cluster') !== targetTab.closest('.tab-cluster');
const stride = window.CodemanTabTriage?.STRIDE || 10000;
const band = (el) => Math.floor((Number(el.style.order) || 0) / stride);
return band(dragged) !== band(targetTab);
}
/** True when the tab list is clustered by case (`tabArrangement: 'case'`, Discussion #426 option A). */
isTabClusters() {
return document.documentElement.dataset.tabArrangement === 'case';
}
/**
* True when the header strip is drawn as a ledger (`tabArrangement: 'ledger'`,
* Discussion #426 option B): the flat list on an aligned column grid with a
* status bar per cell. Pure CSS on `.tabs-ledger`, scoped to the desktop
* header strip; the rail and the sidebar keep their flat list.
*/
isTabLedger() {
return document.documentElement.dataset.tabArrangement === 'ledger';
}
/**
* Which case a session belongs to, for clustering: the case whose path is the
* longest prefix of its working directory (`_mobileOverviewCaseFor()`, the
* home screens' own match), else the directory itself, else the session alone.
*/
_tabClusterIdentity(session, id) {
const dir = (session.workingDir || '').replace(/\/+$/, '');
const match =
dir && typeof this._mobileOverviewCaseFor === 'function' ? this._mobileOverviewCaseFor(dir, this.cases) : null;
if (match) return { key: match.path, label: match.name || '' };
if (dir) return { key: dir, label: dir.split('/').pop() || dir };
return { key: `session:${id}`, label: '' };
}
/**
* The cluster layout for one render pass, or null when the list is not
* clustered. Named groups in the vertical rail win, exactly as for the state
* grouping. `key` is the whole structure as a string: the incremental render
* path compares it with the last full render's and rebuilds when it differs,
* because a cluster is a real box and a patch in place cannot move a tab into
* another one. Membership only changes when sessions come and go (already a
* full rebuild) or when the case list arrives, so this rarely fires.
*
* @param {Array<string>} ids live session ids, in tab order
* @param {object|null} groupProjection the grouped rail's projection, if any
*/
_tabClusterLayout(ids, groupProjection) {
if (groupProjection || !this.isTabClusters() || !window.CodemanTabClusters) return null;
const rows = [];
for (const id of ids) {
const session = this.sessions.get(id);
if (session) rows.push({ id, ...this._tabClusterIdentity(session, id) });
}
const clusters = window.CodemanTabClusters.compute(rows);
// Only a cluster with company drops the case from its tab names.
const labelFor = new Map();
for (const cluster of clusters) {
if (cluster.ids.length > 1) for (const id of cluster.ids) labelFor.set(id, cluster.label);
}
const webviewIds = (this.webviewOrder || []).filter((wid) => this.webviews?.has(wid));
const key = JSON.stringify([clusters.map((c) => [c.key, c.label, c.ids]), webviewIds]);
return { clusters, labelFor, webviewIds, key };
}
/**
* The cluster boxes for the full render: one box per case, labelled with its
* colour swatch, name and count, and a box per open web tab, which has no
* case. The header strip shows only the swatch for a case with one tab
* (styles.css); the rail and the sidebar label every case. Rows are the
* caller's own markup, so a tab is byte-identical to the flat strip's apart
* from its name split.
*/
_renderTabClusters(layout, rowHtml, webviewSlotStart) {
const parts = [];
for (const cluster of layout.clusters) {
const rows = cluster.ids.map((id) => rowHtml.get(id) || '').join('');
const single = cluster.ids.length < 2;
const head =
'<span class="tab-cluster-label" aria-hidden="true"><span class="tab-cluster-swatch"></span>' +
`<span class="tab-cluster-name" data-i18n-skip>${escapeHtml(cluster.label)}</span>` +
`<span class="tab-cluster-count">${cluster.ids.length}</span></span>`;
parts.push(
`<div class="tab-cluster${single ? ' tab-cluster--single' : ''}" role="presentation" data-cluster-key="${escapeHtml(cluster.key)}" style="--cluster-color: var(--session-${cluster.color})">${head}${rows}</div>`
);
}
layout.webviewIds.forEach((wid, i) => {
const tab = this.renderWebviewTab?.(wid, webviewSlotStart + i) || '';
if (tab) parts.push(`<div class="tab-cluster tab-cluster--single tab-cluster--web" role="presentation">${tab}</div>`);
});
return parts.join('');
}
/**
* The tab label, as markup. #232: a described name (`w3-x: fix login`) shows
* just the description, the generated id kept in a hidden prefix span. Inside
* a case cluster a generated `w75-api-gateway` shows `w75`, the `-api-gateway`
* kept in a `.tab-name-case` span that only `.tabs-clusters` hides, so the full
* name stays in the DOM (copy, find-in-page, the rename editor).
*/
_tabNameHtml(name, clusterLabel) {
const parsed = parseSessionPrefix(name);
if (parsed && parsed.suffix) {
return `<span class="tab-name-prefix">${escapeHtml(parsed.prefix)}: </span>${escapeHtml(parsed.suffix)}`;
}
const split = clusterLabel ? window.CodemanTabClusters?.nameSplit(name, clusterLabel) : null;
if (split) return `${escapeHtml(split.shown)}<span class="tab-name-case">${escapeHtml(split.hidden)}</span>`;
return escapeHtml(name);
}
/**
* The arrangement classes on #sessionTabs that are not owned by a sync of
* their own (`tabs-triage` is `_syncTabTriageChrome()`'s): `tabs-clusters`
* while case clusters are drawn, `tabs-ledger` while the ledger is on. The
* ledger never applies inside the grouped rail.
*/
_syncTabArrangementClasses(container, { clusters, groupProjection }) {
if (!container) return;
container.classList.toggle('tabs-clusters', !!clusters);
container.classList.toggle('tabs-ledger', this.isTabLedger() && !groupProjection);
}
/**
* True where the sidebar is a MODAL off-canvas drawer over the terminal
* instead of a docked column.
*
* That behaviour is defined purely in mobile.css, which index.html loads with
* media="(max-width: 1023px)" — so this must test the SAME breakpoint.
* MobileDetection.getDeviceType() is NOT usable here: it calls anything
* >= 768px 'desktop', which would leave 768-1023px (iPad portrait, a narrowed
* desktop window) with overlay CSS but docked-sidebar logic — drawer opens
* itself on load, tapping a session doesn't dismiss it, Escape does nothing.
* Mirrored in the pre-paint script in index.html.
*/
_isSessionSidebarOverlay() {
return window.innerWidth < 1024;
}
/**
* Collapse state is per-device and lives in its OWN localStorage key, not in
* the app-settings blob: saveAppSettings() rebuilds that blob from the DOM
* controls, so any key without a control is silently wiped on every Save.
* Precedent: codeman:skin, codeman-session-order, codeman-active-session.
*/
isSessionSidebarCollapsed() {
// In-memory intent wins over storage: where localStorage throws (Safari
// private mode, disabled storage, quota) the write in toggleSessionSidebar()
// is a no-op, and re-reading here would return the OLD value — the sidebar
// would refuse to collapse at all. Persistence degrades, the control does not.
if (this._sidebarCollapsedOverride !== undefined) return this._sidebarCollapsedOverride;
let raw = null;
try {
raw = localStorage.getItem('codeman-sidebar-collapsed');
} catch {}
// Never chosen yet: the docked desktop sidebar starts open, the overlay
// drawer starts CLOSED — "expanded" there would mean a drawer covering the
// terminal on every cold load.
if (raw === null) return this._isSessionSidebarOverlay();
return raw === '1';
}
/**
* True when this keydown is the sidebar-toggle chord AND toggling would
* actually do something. Used by terminal-ui.js's custom key handler to keep
* the chord out of the PTY: the document CAPTURE handler has already toggled
* the sidebar by the time xterm sees the event, but its preventDefault() does
* NOT stop xterm — without this gate Alt+B would ALSO write ESC b into the
* live session, which readline/Ink read as backward-word and which walks the
* cursor back through whatever the user was typing (same trap as COD-153).
*
* Deliberately registry-aware and gated on the sidebar being active, so a
* rebound/disabled shortcut — and the default header layout, where the toggle
* is a no-op — leave Meta-b reaching the terminal exactly as before.
*/
shouldToggleSessionSidebarFromShortcut(e) {
if (!e) return false;
// Every dispatchable binding requires Ctrl/Cmd/Alt, so plain typing exits
// before any registry work — this runs on the xterm keydown hot path.
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
if (!this.isSessionSidebarActive()) return false;
if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') {
return false;
}
const shortcut = this.getShortcutRegistry().find((s) => s.id === 'toggle-session-sidebar');
if (!shortcut || shortcut.disabled) return false;
return this.matchesShortcutEvent(e, shortcut);
}
/**
* Move the ONE #sessionTabs element between its two hosts and set the layout
* attributes that all the sidebar CSS keys off.
*
* Never clones or recreates the node: this.$('sessionTabs') caches elements by
* id and never invalidates, and settings-ui.js / webview-tabs.js resolve the
* same id independently. A rebuilt container would leave every consumer
* writing into a detached orphan — silently, with no error.
*/
applySessionListLayout() {
const mode = this.getSessionListLayout();
this.applySessionSidebarFontSize();
// 'sidebar' and 'sidebar-rich' are the same column; only row detail differs.
const sidebar = mode === 'sidebar' || mode === 'sidebar-rich';
const collapsed = this.isSessionSidebarCollapsed();
const prevMode = document.documentElement.dataset.sessionList;
const prevDetail = document.documentElement.dataset.sidebarDetail;
const prevCollapsed = document.documentElement.dataset.sidebar;
const tabsEl = document.getElementById('sessionTabs');
const headerHost = document.getElementById('sessionTabsHost');
const sidebarList = document.getElementById('sessionSidebarList');
if (!tabsEl || !headerHost || !sidebarList) return;
const rail = document.getElementById('tabRail');
const railOwnsTabs =
!sidebar && document.documentElement.getAttribute('data-tab-orientation') === 'vertical';
const host = sidebar ? sidebarList : railOwnsTabs && rail ? rail : headerHost;
if (tabsEl.parentElement !== host) host.appendChild(tabsEl);
document.documentElement.dataset.sessionList = sidebar ? 'sidebar' : 'header';
// Detail is meaningless outside the sidebar, and must not linger as 'rich'
// there: the rows carry no meta line in the header strip, and a stale 'rich'
// would let the sidebar CSS style a strip that has nothing to style.
document.documentElement.dataset.sidebarDetail = mode === 'sidebar-rich' ? 'rich' : 'simple';
document.documentElement.dataset.sidebar = collapsed ? 'collapsed' : 'expanded';
tabsEl.setAttribute('aria-orientation', host === headerHost ? 'horizontal' : 'vertical');
const btn = document.getElementById('sidebarToggleBtn');
if (btn) {
btn.classList.toggle('btn-sidebar-toggle--hidden', !sidebar);
const label = collapsed ? 'Expand session sidebar' : 'Collapse session sidebar';
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
btn.setAttribute('aria-label', label);
btn.setAttribute('title', label);
}
// Handheld (mobile.css): the sidebar is an off-canvas overlay, and
// "collapsed" means the drawer is closed.
const aside = document.getElementById('sessionSidebar');
if (aside) {
aside.classList.toggle('open', sidebar && !collapsed);
// A closed overlay drawer is only moved off screen by translateX(-100%);
// it keeps display:flex, so without this its filter box and ~4 tab stops
// per session stay in the Tab order and in the accessibility tree.
// NOT applied to the docked desktop rail — its rows are still clickable.
const hiddenDrawer = sidebar && collapsed && this._isSessionSidebarOverlay();
aside.toggleAttribute('inert', hiddenDrawer);
if (hiddenDrawer) aside.setAttribute('aria-hidden', 'true');
else aside.removeAttribute('aria-hidden');
}
// The filter box only exists inside the sidebar; leaving a stale filter
// applied when the layout goes back to the header strip would hide sessions
// from the tab bar with no reachable control to clear it.
if (!sidebar) {
this._sidebarFilter = '';
const filterInput = document.getElementById('sessionSidebarFilter');
if (filterInput) filterInput.value = '';
}
// applyTabWrapSettings() (settings-ui.js) is the ONE owner of
// tabs-two-rows / tabs-show-folder / _tallTabsEnabled and is itself
// sidebar-aware — it reads the data-session-list attribute set just above,
// so it must run AFTER it. It re-renders by itself when the folder row
// appears or disappears.
const prevTall = this._tallTabsEnabled;
this.applyTabWrapSettings();
// A layout flip alone still needs one render: the rows are rebuilt into the
// new host with the drag/keyboard handlers re-bound. Skipped when
// applyTabWrapSettings() already rendered for the folder-row change.
//
// The detail half of the test is not redundant: simple ⟷ rich leaves
// data-session-list on 'sidebar' both times, so comparing only that would
// flip the setting and repaint nothing until the next SSE tick — and the
// meta line is emitted by the row template, not toggled by CSS.
const layoutChanged =
prevMode !== document.documentElement.dataset.sessionList ||
prevDetail !== document.documentElement.dataset.sidebarDetail;
const collapseChanged = prevCollapsed !== document.documentElement.dataset.sidebar;
if ((layoutChanged || collapseChanged) && prevTall === this._tallTabsEnabled) {
this._fullRenderSessionTabs();
}
// tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode()
// drops it in sidebar mode, but drop it here too so nothing paints wrapped
// for a frame before the next measure.
if (sidebar) tabsEl.classList.remove('tabs-auto-wrap');
// Collapse/expand changes whether the filter is reachable, so re-evaluate it
// here too — not only at the render tails.
this.applySidebarFilter(this._sidebarFilter);
this.updateConnectionLines();
// The desktop home rail defers to the sidebar (both dock the session list
// flush left), so a layout flip while the welcome screen is up has to
// re-evaluate it — showHomeSessions() self-gates on shouldShowHomeSessions().
if (document.getElementById('welcomeOverlay')?.classList.contains('visible')) {
this.showHomeSessions?.();
}
// Only the rich rows carry stamps that go stale with no event behind them.
if (this.isRichTabRows()) this._startSidebarRichClock();
else this._stopSidebarRichClock();
}
toggleSessionSidebar() {
if (!this.isSessionSidebarActive()) return;
const collapsed = !this.isSessionSidebarCollapsed();
this._sidebarCollapsedOverride = collapsed;
try {
localStorage.setItem('codeman-sidebar-collapsed', collapsed ? '1' : '0');
} catch {}
// Collapsing hides the filter row. If focus is sitting in there it would be
// reset to <body>, dropping the user back to the top of the tab order — so
// hand it to the toggle, which is the control they just used.
if (collapsed && this.$('sessionSidebar')?.contains(document.activeElement)) {
document.getElementById('sidebarToggleBtn')?.focus();
}
this.applySessionListLayout();
// Opening the MODAL drawer moves focus into it, as a dialog should. The
// docked desktop sidebar is not modal: stealing focus there would pull the
// caret out of the terminal mid-prompt, and .session-tab handles only
// arrows/Home/End/Enter/Space, so everything typed after would be swallowed.
if (!collapsed && this._isSessionSidebarOverlay()) {
this.$('sessionTabs')?.querySelector('.session-tab.active')?.focus();
}
}
/**
* Overlay layouts only: below 1024px the sidebar is a modal drawer on top of
* the terminal (mobile.css), so picking a session from it must get it out of
* the way again. The docked desktop sidebar stays exactly where the user put
* it. No-op unless the drawer is actually open.
*/
closeSessionSidebarOnHandheld() {
if (!this._isSessionSidebarOverlay()) return;
if (!this.isSessionSidebarActive() || this.isSessionSidebarCollapsed()) return;
this.toggleSessionSidebar();
}
/**
* The count is what is actually ON the list: session rows plus web-tab rows,
* minus whatever the sidebar filter is hiding. `this.sessions.size` was the
* original source and disagreed with the screen twice over — web tabs render
* in the same list but are not sessions (3 sessions + 2 dashboards read "3"
* above 5 rows), and a filter hides rows without touching the map. Counting
* the rendered rows keeps one source of truth: the list itself.
*/
updateSidebarCount() {
const el = document.getElementById('sessionSidebarCount');
if (!el) return;
const container = this.$('sessionTabs');
const count = container
? container.querySelectorAll('.session-tab:not(.tab-filtered-out)').length
: (this.sessions?.size ?? 0);
el.textContent = String(count);
}
/**
* Sidebar filter box. Pure DOM class toggling — no re-render, no state on the
* sessions themselves. Matches the rendered aria-label (session name) and the
* title (working directory).
*
* Re-applied at the tail of both render paths: _fullRenderSessionTabs() rebuilds
* innerHTML wholesale, so without that the filtered-out rows flicker back in on
* every SSE tick.
*
* The filter only takes effect while the box that produced it is on screen —
* i.e. the expanded sidebar. In the header strip, the collapsed rail or a
* closed drawer the classes come off, otherwise sessions would stay hidden
* with no visible cause and no reachable control to clear them. The remembered
* needle is restored when the box comes back.
*/
applySidebarFilter(query) {
this._sidebarFilter = (query ?? '').trim().toLowerCase();
const container = this.$('sessionTabs');
if (!container) return;
const reachable =
this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
const needle = reachable ? this._sidebarFilter : '';
// State headings count the whole group, so they step aside while a filter
// is narrowing the rows under them (styles.css, .tabs-filtering).
container.classList.toggle('tabs-filtering', !!needle);
for (const tab of container.querySelectorAll('.session-tab')) {
if (!needle) {
tab.classList.remove('tab-filtered-out');
continue;
}
const haystack = `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`.toLowerCase();
tab.classList.toggle('tab-filtered-out', !haystack.includes(needle));
}
// The count shows visible rows, so it moves with every filter change —
// including keystrokes in the filter box, which call this directly.
this.updateSidebarCount();
}
// ═══════════════════════════════════════════════════════════════
// Rich sidebar rows (sessionListLayout === 'sidebar-rich')
// ═══════════════════════════════════════════════════════════════
/**
* Pill copy per state, matching the desktop home rail and the phone overview
* word for word. Duplicated rather than imported for the same reason those two
* duplicate it from each other: it is six words, and constants.js is served
* from cache independently of app.js — a shared map there could arrive stale
* or missing while this file is new. What is NOT duplicated is the part that
* can actually disagree: which state a session is IN, and which stamp measures
* it, both of which come from mobile-overview.js below.
*/
_sidebarRichPillLabel(state) {
return {
needs: 'needs you',
error: 'error',
waiting: 'waiting',
working: 'working',
idle: 'idle',
done: 'done',
}[state] || state;
}
/**
* The per-row model for a rich row (detailed sidebar or vertical tab rail):
* which state the session is in, when it was first created, and how long it
* has been in that state.
*
* Classification is `_mobileOverviewState()` and the state duration is
* `_mobileOverviewSince()` (both mobile-overview.js), NOT re-derived here —
* the sidebar, the rail, the desktop home rail and the phone overview must
* never disagree about what "working" means or about which stamp measures it.
*
* Guarded like every other cross-file consumer in this app: a stale cached
* mobile-overview.js must degrade to a row with no meta line, not throw and
* take the whole tab strip down with it.
*/
_sidebarRichRow(id, session) {
if (typeof this._mobileOverviewState !== 'function') return null;
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
// An exited agent (Ark0N/Codeman#446) overrides the LABEL, never the state:
// `state` keys SESSION_ACTIVITY_RANK and the sort, while `status` stays idle
// or busy for an exited pane by design, so without this the muted dot sits
// beside a pill saying "idle". A pending alert still wins, exactly as it
// does for the dot. The rule is `_mobileOverviewExit()`, shared with both
// home screens so the three surfaces agree on which sessions have exited.
const exit = this._mobileOverviewExit ? this._mobileOverviewExit(state, session) : null;
const exited = !!exit;
return {
state,
exited,
pill: exited ? 'exited' : this._sidebarRichPillLabel(state),
// What the pane's own footer says is still running in the background ("1 monitor",
// "2 shells"). A row that has one went quiet because the agent is waiting for that,
// which is a different thing from waiting for the user — so it rides BESIDE the
// state pill and never replaces it.
watching: typeof session.watching === 'string' ? session.watching : '',
createdAt: Number(session.createdAt) || 0,
since: exit ? exit.since : this._mobileOverviewSince ? this._mobileOverviewSince(state, session) : null,
};
}
/**
* The "created 3d ago · working 12m" line plus the status pill, as the third
* child of `.tab-info` (already a flex column, so no row-level wrapping is
* needed — unlike the home rail, whose pill rides a wrapped full-width line).
*
* Both stamps keep their raw epoch-ms in `data-tab-ts` so
* `_tickSidebarRichTimes()` can rewrite the text without rebuilding the row:
* a rebuild would restart the load spinner and every alert animation in the
* list, twice a minute, for nothing.
*
* Returns '' when there is no model, which is what keeps the header strip and
* the simple sidebar byte-identical to before.
*/
_sidebarRichMetaHTML(row) {
if (!row) return '';
const stamp = (key, ts, fmt, cls) => {
const text = this._sidebarRichStampText(ts, fmt);
const title = ts
? ` title="${escapeHtml(`${key === 'created' ? 'First created' : key}: ${new Date(ts).toLocaleString()}`)}"`
: '';
return `<span class="tab-meta-item ${cls}"${title}><span class="tab-meta-key">${escapeHtml(key)}</span><span data-tab-ts="${ts || 0}" data-tab-fmt="${fmt}">${escapeHtml(text)}</span></span>`;
};
// data-i18n-skip: relative times are generated text, and "created"/"idle"
// are the same generic words that mean something else on other surfaces.
const parts = [stamp('created', row.createdAt, 'ago', 'tab-meta-created')];
if (row.since) {
parts.push('<span class="tab-meta-sep" aria-hidden="true">\u00B7</span>');
parts.push(stamp(row.since.key, row.since.at, 'for', 'tab-meta-since'));
}
const pillMod = row.exited ? 'exited' : row.state;
parts.push(`<span class="tab-pill tab-pill--${escapeHtml(pillMod)}">${escapeHtml(row.pill)}</span>`);
// The word is duplicated from mobile-overview.js for the same reason the pill labels
// above are: it is one word, and this file must render a complete row even when a
// stale cached mobile-overview.js has arrived without it.
// The visible text is that constant. The pane-derived label appears only in the
// tooltip, where escapeHtml() (which escapes both quote characters) is what this file
// already relies on for every untrusted string it puts in an attribute, and where the
// source caps it at MAX_WATCHING_LABEL_CHARS before it ever gets here.
if (row.watching) {
const title = escapeHtml(`Still running in the background: ${row.watching}`);
parts.push(`<span class="tab-pill tab-pill--watching" title="${title}">watching</span>`);
}
// Both absolute stamps ALSO on the line itself, not only on the two items.
// Below 288px the rail hides `.tab-meta-created` (the `tab-rail-tight`
// rule), and a tooltip on a `display: none` element has no hover target —
// so without this the created stamp is not merely shrunk, it is gone with
// no way to ask for it. The pill and the gaps around the stamps are the
// hover targets that remain; an item's own title still wins over this one
// where the item is visible.
const lineTitle = [
row.createdAt ? `First created: ${new Date(row.createdAt).toLocaleString()}` : '',
row.since && row.since.at ? `${row.since.key}: ${new Date(row.since.at).toLocaleString()}` : '',
]
.filter(Boolean)
.join(' \u00B7 ');
const lineTitleAttr = lineTitle ? ` title="${escapeHtml(lineTitle)}"` : '';
return `<span class="tab-meta"${lineTitleAttr} data-i18n-skip>${parts.join('')}</span>`;
}
/** Same formatter as both home screens, so a duration is written the same way everywhere. */
_sidebarRichStampText(timestamp, format) {
return this._mobileOverviewStampText ? this._mobileOverviewStampText(timestamp, format) : '\u2014';
}
/**
* Incremental-render counterpart of `_sidebarRichMetaHTML()`. The stamps move
* on the clock, but the STATE can change between renders (a session starts
* working, a permission prompt lands), and that flips the pill, the accent
* class and which stamp the second slot is even showing.
*
* Rebuilds the meta line only when something it displays actually changed,
* because this runs for every session on every SSE tick.
*/
_updateSidebarRichRow(tab, id, session) {
const row = this._sidebarRichRow(id, session);
if (!row) return;
const prev = tab.dataset.tabState;
// The since ANCHOR moves without the state changing (each new turn re-stamps
// lastSubmitAt), so key the compare on both.
// Unescaped on purpose, and it still matches the attribute the initial render wrote:
// that one goes through escapeHtml() because it is interpolated into markup, and the
// browser hands the decoded string back through `dataset`. `watching` is the only
// pane-derived value in this signature, which is why it is the only one escaped there.
const sig = `${row.state}${row.exited ? '+exited' : ''}:${row.since ? row.since.at : 0}:${row.createdAt}:${row.watching}`;
if (tab.dataset.tabMetaSig === sig) return;
tab.dataset.tabMetaSig = sig;
tab.dataset.tabState = row.state;
if (prev) tab.classList.remove(`tab-state-${prev}`);
tab.classList.add(`tab-state-${row.state}`);
const info = tab.querySelector('.tab-info');
if (!info) return;
const html = this._sidebarRichMetaHTML(row);
const existing = info.querySelector('.tab-meta');
if (existing) existing.outerHTML = html;
else info.insertAdjacentHTML('beforeend', html);
}
/**
* Rewrites the relative stamps in place. A session that is just sitting there
* emits no event at all, so without this its "idle 2m" would still read 2m an
* hour later — the one number in the list that has to move on its own.
*/
_startSidebarRichClock() {
if (this._sidebarRichClock) return;
this._sidebarRichClock = setInterval(() => {
if (!this.isRichTabRows()) {
this._stopSidebarRichClock();
return;
}
this._tickSidebarRichTimes();
}, SIDEBAR_RICH_CLOCK_MS);
}
_stopSidebarRichClock() {
if (!this._sidebarRichClock) return;
clearInterval(this._sidebarRichClock);
this._sidebarRichClock = null;
}
_tickSidebarRichTimes() {
const container = this.$('sessionTabs');
if (!container) return;
for (const node of container.querySelectorAll('[data-tab-ts]')) {
const text = this._sidebarRichStampText(Number(node.dataset.tabTs) || 0, node.dataset.tabFmt);
if (node.textContent !== text) node.textContent = text;
}
}
// ═══════════════════════════════════════════════════════════════
// Session Tabs
// ═══════════════════════════════════════════════════════════════
renderSessionTabs() {
// Don't re-render while user is typing in the inline rename input
if (this._inlineRenameActive) return;
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
}
/** Toggle .active class on tabs immediately (no debounce). Used by selectSession(). */
_updateActiveTabImmediate(sessionId) {
const container = this.$('sessionTabs');
if (!container) return;
// Grouped rail: selecting a session hidden in a collapsed group must show it
// (and re-hide the previous exception), which a class toggle cannot do.
if (this._isTabGroupStructureStale()) {
this._fullRenderSessionTabs();
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');
}
}
this._syncTabTreeSelection(container);
// #257: selection used to stop at the class toggle. On phones/tablets the
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
// Alt+N or a push notification could stay parked off-screen.
this._scrollActiveTabIntoView(sessionId);
// Where the new active tab sits now, so the render pass that follows can
// tell when it changes state band (viewing a waiting tab spends its alert
// and drops it into the idle row).
this._noteActiveTabBand(container);
// Lineage lines draw only the SELECTED tab's family (session-lineage.js), so a
// selection change is a redraw whenever any lineage exists at all.
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
}
/**
* Scroll the tab strip so the given (default: active) tab is visible.
*
* Only phones/tablets scroll the strip (desktop wraps to a second row), and
* the pure policy no-ops whenever there is nothing to scroll, so this is a
* cheap call on every device.
*
* Deliberately NOT scrollIntoView(): that also scrolls every scrollable
* ANCESTOR, which on a phone is the document itself. With the header fixed
* and the keyboard possibly open, a vertical nudge there shifts the whole
* app. Rect math + scrollLeft touches exactly one scroller.
*/
_scrollActiveTabIntoView(sessionId, behavior = 'smooth') {
const container = this.$('sessionTabs');
if (!container) return;
const tab =
(sessionId && container.querySelector(`.session-tab[data-id="${sessionId}"]`)) ||
container.querySelector('.session-tab.active');
if (!tab) return;
// Sidebar layout AND the vertical rail: the list scrolls VERTICALLY in its
// own scroller, so the horizontal computeTabScrollLeft math below would
// always no-op (scrollLeft pinned at 0). With 25+ sessions the active row
// is routinely below the fold; 'nearest' never scrolls when it is already
// visible, and only the list's own scroller moves — drawer/rail and
// document stay put.
if (this._isVerticalTabList()) {
tab.scrollIntoView({ block: 'nearest' });
return;
}
const policy = window.CodemanTabOverflow?.computeTabScrollLeft;
if (!policy) return;
const containerRect = container.getBoundingClientRect();
const tabRect = tab.getBoundingClientRect();
const target = policy({
scrollLeft: container.scrollLeft,
clientWidth: container.clientWidth,
scrollWidth: container.scrollWidth,
// Offsets are relative to the SCROLL CONTENT, not the offsetParent: the
// tabs' offsetParent is the positioned header, so offsetLeft would carry
// the brand column's width into the math.
tabLeft: tabRect.left - containerRect.left + container.scrollLeft,
tabWidth: tabRect.width,
});
if (Math.abs(target - container.scrollLeft) < 1) return;
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
if (typeof container.scrollTo === 'function') {
container.scrollTo({ left: target, behavior: reduceMotion ? 'auto' : behavior });
} else {
container.scrollLeft = target;
}
}
/**
* Record the active tab's state band and report whether it MOVED band since
* the last record while staying the active tab.
*
* Grouped by state (tabArrangement 'state'), the phone/tablet strip is still
* one scrolling row, but its tabs come in bands of `order` values, one band
* per state (CodemanTabTriage, constants.js). The active session changing
* state (a prompt sent: idle to working; a permission prompt: needs you)
* moves its tab into another band while scrollLeft stays put, so the tab in
* use slid out of view (measured at 390px: x 165 to -870). Both render paths
* reveal it when this says so.
*
* The band, not the raw order: another tab entering or leaving the active
* tab's band shifts its order value by one, and that must not yank a strip
* the user may be browsing. A changed active tab is not a move either: the
* switch already revealed it (#257). Read off the element, so the record is
* what is on screen, and called from _updateActiveTabImmediate() too, so a
* band change in the pass right after a switch still counts.
*
* @returns {boolean}
*/
_noteActiveTabBand(container) {
const id = this.activeWebviewId ? null : this.activeSessionId;
const tab = id && container ? container.querySelector(`.session-tab[data-id="${id}"]`) : null;
const prev = this._activeTabBand;
if (!tab) {
this._activeTabBand = null;
return false;
}
const order = tab.style.order;
const stride = window.CodemanTabTriage?.STRIDE || 10000;
const band = order === '' ? null : Math.floor(Number(order) / stride);
this._activeTabBand = { id, band };
return !!prev && prev.id === id && prev.band !== band;
}
/**
* True when the tab list is the header's one horizontally scrolling row
* (phones and tablets): neither wrapping into rows nor a vertical list.
*/
_isScrollingTabRow(container) {
return (
!this._isVerticalTabList() &&
!container.classList.contains('tabs-auto-wrap') &&
!container.classList.contains('tabs-two-rows')
);
}
/**
* Where a floating window (subagent / ultracode) attaches to its parent tab.
* Header strip: below the tab, connector runs vertically. Sidebar AND the
* vertical rail: to the RIGHT of the tab, connector runs horizontally —
* otherwise the window spawns on top of the list and its bezier loops
* backwards underneath it.
*/
_tabAnchor(rect) {
if (this._isVerticalTabList()) {
return {
x: rect.right,
y: rect.top + rect.height / 2,
spawnLeft: rect.right + 14,
spawnTop: rect.top,
vertical: false,
};
}
return {
x: rect.left + rect.width / 2,
y: rect.bottom,
spawnLeft: rect.left,
spawnTop: rect.bottom,
vertical: true,
};
}
/** Bezier from a _tabAnchor() to a window rect, curving along the right axis. */
_tabConnectorPath(anchor, winRect) {
if (anchor.vertical) {
const x2 = winRect.left + winRect.width / 2;
const y2 = winRect.top;
const midY = (anchor.y + y2) / 2;
return `M ${anchor.x} ${anchor.y} C ${anchor.x} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
}
const x2 = winRect.left;
const y2 = winRect.top + winRect.height / 2;
const midX = (anchor.x + x2) / 2;
return `M ${anchor.x} ${anchor.y} C ${midX} ${anchor.y}, ${midX} ${y2}, ${x2} ${y2}`;
}
_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();
}
}
_renderSessionTabsImmediate() {
// Same guard as renderSessionTabs()/_fullRenderSessionTabs(): the incremental
// branch below rewrites .tab-name's innerHTML, which destroys the inline rename
// <input> mid-keystroke. Guarding only the scheduler is not enough: a render
// debounced just BEFORE the rename opened still fires ~100ms later and lands
// here directly. finishRename() re-renders on both commit and cancel, so a
// render dropped here is picked back up when the rename settles.
if (this._inlineRenameActive) return;
const container = this.$('sessionTabs');
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
// Grouped rail: a collapsed group keeps its rows out of the DOM, so compare
// against the rows the projection SHOWS, not every live session.
const groupProjection = this._projectTabGroups();
const currentIds = groupProjection
? new Set(groupProjection.visibleRefs.filter((ref) => ref.kind === 'session').map((ref) => ref.id))
: new Set(this.sessions.keys());
// Web tabs live in the same strip but are not in this.sessions, so they need
// their own change check. Without it, the session-only comparison below is
// vacuously "unchanged" whenever session count is stable — most visibly with
// ZERO sessions (0 === 0), where opening a dashboard would never draw its tab.
const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map(
t => t.dataset.webviewId
);
const wantedWebIds = groupProjection
? groupProjection.visibleRefs.filter((ref) => ref.kind === 'webview').map((ref) => ref.id)
: (this.webviewOrder || []).filter(id => this.webviews?.has(id));
const webTabsUnchanged =
existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]);
// Check if we can do incremental update (same session IDs and same web tabs)
// The grouped rail's structure (sections, collapse, the shown exception) can
// change while the id sets stay equal; the in-place patch below cannot move
// or hide a row, so any structural change takes the full rebuild.
// Case clusters are boxes too: the same rule, keyed on their structure.
const clusterLayout = this._tabClusterLayout(
this.sessionOrder.filter((sid) => this.sessions.has(sid)),
groupProjection
);
const canIncremental = existingIds.size === currentIds.size &&
[...existingIds].every(id => currentIds.has(id)) &&
webTabsUnchanged &&
!this._isTabGroupStructureStale(groupProjection) &&
(clusterLayout ? clusterLayout.key : null) === (this._lastTabClusterKey ?? null);
let activeBandMoved = false;
if (canIncremental) {
// Read once for the whole pass, like the full-rebuild path: this touches
// the DOM and the loop below runs for every session on every SSE tick.
const richRows = this.isRichTabRows();
// Sorted vertical rail: a state change moves a card, and this is the path
// that sees one — a session going working→idle never adds or removes a
// tab, so the full rebuild below is not reached. Recomputed per pass for
// the same reason the rich meta line is: the order IS the state.
const liveIds = this.sessionOrder.filter((sid) => this.sessions.has(sid));
const railSortOrder = this._tabRailSortOrder(liveIds);
// Grouped by state: same reasoning, a state change moves a tab between
// rows. Its order values replace the rail sort's (which it already folded
// in as the rank inside each section).
const triage = this._tabTriageLayout(liveIds, groupProjection, railSortOrder);
const listOrder = triage ? triage.order : railSortOrder;
// 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;
// An empty string clears the property, which is also what un-sorts the
// rail when the setting (or the layout) flips without a full rebuild.
const railOrder = listOrder?.has(id) ? String(listOrder.get(id)) : '';
if (tab.style.order !== railOrder) tab.style.order = railOrder;
// A web tab owns the active state while one is open. activeSessionId stays
// set (the terminal keeps streaming underneath, and switching back is
// instant): only the highlight moves. Without this the debounced render
// re-marks the session tab active moments after a web tab was selected,
// leaving two tabs lit at once.
const isActive = id === this.activeSessionId && !this.activeWebviewId;
const status = session.status || 'idle';
const name = this.getSessionName(session);
const taskStats = session.taskStats || { running: 0, total: 0 };
const hasRunningTasks = taskStats.running > 0;
const loadState = this.terminalLoadStates.get(id);
// Update active class
if (isActive && !tab.classList.contains('active')) {
tab.classList.add('active');
} else if (!isActive && tab.classList.contains('active')) {
tab.classList.remove('active');
}
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();
}
// 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'); }
// Tile grid membership (tile-grid.js): a tiled session's tab says so.
tab.classList.toggle('in-tiles', !!this._tileGrid?.has(id));
// 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);
}
}
// Update status indicator
const statusEl = tab.querySelector('.tab-status');
if (statusEl && !statusEl.classList.contains(status)) {
statusEl.className = `tab-status ${status}`;
}
// The exited-agent badge (Ark0N/Codeman#446). A session going from live
// to exited changes no tab count, so the full rebuild below never runs
// for it and this is the only path that ever draws the badge.
applyPaneExitBadge(tab, session.paneExit);
// Rich sidebar meta ("created 3d ago · working 12m" + pill). The stamps
// themselves move on _tickSidebarRichTimes(); this is here for the parts
// a tick cannot see — the state flipping, and with it the pill, the row
// accent and which stamp the second slot is measuring at all.
if (richRows) {
this._updateSidebarRichRow(tab, id, session);
} else if (tab.dataset.tabState) {
// Layout flipped away from rich without a full rebuild reaching this
// row yet: strip the line rather than leave a frozen stamp behind.
tab.querySelector('.tab-meta')?.remove();
tab.classList.remove(`tab-state-${tab.dataset.tabState}`);
delete tab.dataset.tabState;
delete tab.dataset.tabMetaSig;
}
// Update name if changed. #232: a description (the `: suffix` part of the
// name) is the whole tab label; the generated id lives in the tooltip. The
// compare targets the DISPLAY text, or a described tab would re-render on
// every pass (textContent never equals the full name there).
const nameEl = tab.querySelector('.tab-name');
if (nameEl) {
const _p = parseSessionPrefix(name);
if (nameEl.dataset.fullName !== name) {
nameEl.replaceChildren();
const _split = _p && _p.suffix ? null : window.CodemanTabClusters?.nameSplit(name, clusterLayout?.labelFor.get(id));
if (_p && _p.suffix) {
const prefix = document.createElement('span');
prefix.className = 'tab-name-prefix';
prefix.textContent = `${_p.prefix}: `;
nameEl.append(prefix, document.createTextNode(_p.suffix));
} else if (_split) {
const caseSpan = document.createElement('span');
caseSpan.className = 'tab-name-case';
caseSpan.textContent = _split.hidden;
nameEl.append(document.createTextNode(_split.shown), caseSpan);
} else {
nameEl.textContent = name;
}
nameEl.dataset.fullName = name;
tab.title = _p && _p.suffix
? (session.workingDir ? `${_p.prefix} (${session.workingDir})` : _p.prefix)
: (session.workingDir || '');
}
}
// Update task badge
const badgeEl = tab.querySelector('.tab-badge');
if (hasRunningTasks) {
if (badgeEl) {
if (badgeEl.textContent !== String(taskStats.running)) {
badgeEl.textContent = taskStats.running;
}
} else {
// Need to add badge - do full rebuild
this._fullRenderSessionTabs();
return;
}
} else if (badgeEl) {
// Need to remove badge - do full rebuild
this._fullRenderSessionTabs();
return;
}
// Update subagent badge - targeted update without full rebuild
const subagentBadgeEl = tab.querySelector('.tab-subagent-badge');
const minimizedAgents = this.minimizedSubagents.get(id);
const minimizedCount = minimizedAgents?.size || 0;
if (minimizedCount > 0 && subagentBadgeEl) {
// Badge exists and still has agents - update label and dropdown in-place
const labelEl = subagentBadgeEl.querySelector('.subagent-label');
const newLabel = minimizedCount === 1 ? 'AGENT' : `AGENTS (${minimizedCount})`;
if (labelEl && labelEl.textContent !== newLabel) {
labelEl.textContent = newLabel;
}
// Rebuild dropdown items (agent list may have changed)
const dropdownEl = subagentBadgeEl.querySelector('.subagent-dropdown');
if (dropdownEl) {
const newBadgeHtml = this.renderSubagentTabBadge(id, minimizedAgents);
const temp = document.createElement('div');
temp.innerHTML = newBadgeHtml;
const newDropdown = temp.querySelector('.subagent-dropdown');
if (newDropdown) {
dropdownEl.innerHTML = newDropdown.innerHTML;
}
}
} else if (minimizedCount > 0 && !subagentBadgeEl) {
// Need to add badge - insert before the action-icon overlay so the
// badge stays a direct child of the tab (outside .tab-actions)
const badgeHtml = this.renderSubagentTabBadge(id, minimizedAgents);
const actionsEl = tab.querySelector(':scope > .tab-actions');
if (actionsEl) {
actionsEl.insertAdjacentHTML('beforebegin', badgeHtml);
} else {
tab.insertAdjacentHTML('beforeend', badgeHtml);
}
} else if (minimizedCount === 0 && subagentBadgeEl) {
// Count went to 0 - remove badge
subagentBadgeEl.remove();
}
}
// Grouped tree: the loop above can re-sort rows (`style.order`), move the
// highlight, and change the alerts a collapsed header stands in for, none
// of which rebuilds the rail. Keep what assistive tech and the headers
// report in step. The flat list (role=tablist) never takes this branch.
if (groupProjection && container.getAttribute('role') === 'tree') {
this._syncTabTreeSelection(container);
this._applyTabTreePositions(container);
this._syncTabGroupHeaderAlerts(container, groupProjection);
}
this._syncTabTriageChrome(container, triage);
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
// A state change is this path's job, and so is the active tab changing
// state band: revealed below, once the wrap mode is current.
activeBandMoved = this._noteActiveTabBand(container);
} else {
// Full rebuild needed (sessions added/removed)
this._fullRenderSessionTabs();
}
// Keep the reveal-on-change bookkeeping honest when only the incremental
// branch ran: _updateActiveTabImmediate has already scrolled the new active
// tab into view, so the next full rebuild must not treat it as a change.
this._lastRenderedActiveTabId = this.activeSessionId;
this.updateTabOverflowMode();
if (activeBandMoved && this._isScrollingTabRow(container)) this._scrollActiveTabIntoView(this.activeSessionId);
// After the wrap measurement: the `unroll` style starts tabs at max-width 0,
// so measuring mid-animation would decide the wrap on collapsed widths.
this._applyTabEntrances?.();
// Phone overview rides on this one call: every state change it cares about
// (create, delete, idle, working, exit, hook alerts via updateTabAlertFromHooks)
// already funnels through here. No-ops unless that surface is showing.
this._refreshMobileOverviewIfVisible?.();
// Same deal for the desktop home screen's tab column.
this._refreshHomeSessionsIfVisible?.();
// The full-render path already redraws the connection SVG; this incremental
// one does not, and a badge appearing widens a tab and shifts every tab after
// it, sliding the lineage lines off their anchors. Only pay for it when there
// is something anchored to tab rects: any lineage at all (not just what is
// drawn now: a parentSessionId or a child's working state can arrive on this
// path and change the selected family's tree), or, in a VERTICAL list (the
// sidebar, where lineage is skipped, or the rail, which can show connectors
// with zero lineage edges too), the subagent/ultracode connectors, whose
// rows a badge changes the HEIGHT of. updateTabOverflowMode() above has just
// refreshed _lineageTotalEdges.
if (this._lineageTotalEdges > 0 || this._isVerticalTabList()) this.updateConnectionLines();
this.applySidebarFilter(this._sidebarFilter);
}
// 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;
// Lineage routing room (session-lineage.js) changes the strip's padding and
// row gap, so it is decided before the wrap is measured below.
this._syncLineageGutter?.();
// The sidebar list is a single vertical column with its own scroller —
// there is no row to overflow, and measuring it would fight the CSS.
if (this.isSessionSidebarActive()) {
container.classList.remove('tabs-auto-wrap');
return;
}
const deviceType = MobileDetection.getDeviceType();
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const manualTwoRows = deviceType === 'desktop' ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) : false;
const orientation = window.CodemanTabOverflow?.resolveTabOrientation
? window.CodemanTabOverflow.resolveTabOrientation({
deviceType,
setting: settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal',
})
: 'horizontal';
if (orientation === 'vertical') {
container.classList.remove('tabs-auto-wrap');
return;
}
if (manualTwoRows || deviceType !== 'desktop') {
container.classList.remove('tabs-auto-wrap');
// Wrap decided: the state labels' column follows it (_sizeTabTriageGutter).
this._sizeTabTriageGutter(container, this._lastTabTriage);
return;
}
// Grouped by state, the header strip IS rows (one per state), so it always
// wraps: the row breaks only take effect in a wrapping flex line. The ledger
// is a grid of rows, so the same holds. Narrower screens keep the single
// scrolling row above, where state headings read as inline dividers and the
// ledger stays the plain strip.
if (container.classList.contains('tabs-triage') || container.classList.contains('tabs-ledger')) {
container.classList.add('tabs-auto-wrap');
this._sizeTabTriageGutter(container, this._lastTabTriage);
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,
innerWrap: container.classList.contains('tabs-clusters') && this._tabClustersWrapInside(container),
})
: container.scrollWidth > container.clientWidth + 1;
container.classList.toggle('tabs-auto-wrap', shouldWrap);
}
/**
* True when a case cluster in the header strip wraps inside its own box: a case
* wider than the whole strip (styles.css caps a box at the strip's width). The
* strip then has rows although it never overflows, so it must still wrap, or
* the lineage routing room (`.lineage-tree.tabs-auto-wrap`) is never reserved
* and the routes have no gap between the box's rows to run in. Read right
* after the overflow measure, so layout is already clean.
*/
_tabClustersWrapInside(container) {
for (const box of container.querySelectorAll(':scope > .tab-cluster')) {
const tabs = box.querySelectorAll(':scope > .session-tab');
if (tabs.length > 1 && tabs[tabs.length - 1].offsetTop > tabs[0].offsetTop + 4) return true;
}
return false;
}
// Middle-click closes a tab, mirroring browser tab strips. Session tabs go
// through requestCloseSession (the same confirm modal as the x button), web
// tabs through closeWebviewTab (same as theirs). Delegated on the container:
// tabs are re-rendered wholesale, the container is stable.
_setupTabMiddleClickClose() {
const container = this.$('sessionTabs');
if (!container || this._tabAuxClickBound) return;
this._tabAuxClickBound = true;
container.addEventListener('auxclick', (e) => {
if (e.button !== 1) return;
const tab = e.target.closest?.('.session-tab');
if (!tab) return;
e.preventDefault();
e.stopPropagation();
if (tab.dataset.id) this.requestCloseSession(tab.dataset.id);
else if (tab.dataset.webviewId) this.closeWebviewTab?.(tab.dataset.webviewId);
});
}
_fullRenderSessionTabs() {
this.closeTabRailActionMenu?.();
if (this._inlineRenameActive) return;
// The group menu's trigger is about to be replaced.
this.closeTabGroupMenu();
const container = this.$('sessionTabs');
// Sidebar rows are always tall (name + folder) and never wrap. Re-assert it
// here so a render triggered straight from applyTabWrapSettings() — which
// only knows the header strip — cannot leave the sidebar folderless.
if (this.isSessionSidebarActive()) {
this._tallTabsEnabled = true;
container.classList.add('tabs-show-folder');
container.classList.remove('tabs-two-rows', 'tabs-auto-wrap');
}
// Clean up any orphaned dropdowns before re-rendering
document.querySelectorAll('body > .subagent-dropdown').forEach(d => d.remove());
this.cancelHideSubagentDropdown();
// #257: replacing innerHTML below resets scrollLeft to 0. On phones the
// strip scrolls, and ambient rebuilds (a task badge appearing, a session
// created elsewhere) fire often enough that a user swiping toward the
// right-hand tabs kept getting yanked back to the first one. Remember
// where the strip was; the browser clamps the restore to the new content.
const prevScrollLeft = container.scrollLeft;
// Sidebar layout scrolls the same container VERTICALLY, so it needs the
// same protection on the other axis.
const prevScrollTop = container.scrollTop;
const prevActiveTabId = this._lastRenderedActiveTabId;
const isFirstRender = !container.querySelector('.session-tab');
// The rebuild below destroys the focused row. In the grouped tree, put focus
// back on the same item (by identity) so a background render or a keyboard
// collapse does not drop a keyboard user to <body>.
// An edit made from a menu (focus now on <body>) asks to land back in the rail.
const focusWasInside = container.contains(document.activeElement) || this._tabRefocusAfterEdit === true;
const focusIdentity = this._tabFocusIdentity || (focusWasInside ? this._tabTreeIdentity(document.activeElement) : null);
this._tabFocusIdentity = null;
this._tabRefocusAfterEdit = false;
// Build tabs HTML using array for better string concatenation performance.
// Iterate in sessionOrder to respect the user's custom tab arrangement, on
// EVERY device: mobile used to hoist the active session to the front, from
// when only one tab fit on screen. With five tabs it made the strip jump
// under the user's finger (and renumbered the Alt+N badges) on every full
// rebuild, while the incremental path left the order alone, so the order
// depended on which render path happened to run. Scrolling the active tab
// into view replaces it.
const parts = [];
const tabOrder = this.sessionOrder;
// Read once, not per session: isRichTabRows() touches the DOM and
// this loop runs for every tab on every full rebuild.
const richRows = this.isRichTabRows();
// The sorted vertical rail (tabRailSort) moves cards with the flex `order`
// property and leaves this loop iterating tab order, so the Alt+N badge
// below still counts the strip, not the sorted list. Null in every other
// layout, and the tabs then carry no inline order at all — the header
// strip's markup is byte-identical to before.
const liveIds = tabOrder.filter((id) => this.sessions.has(id));
const railSortOrder = this._tabRailSortOrder(liveIds);
// Grouped by state (tabArrangement 'state', opt-in): the same `order` mechanism,
// one band of values per state. Null in the grouped rail and with grouping
// off, and the rows then carry exactly the inline order they did before.
const groupProjection = this._projectTabGroups();
const triage = this._tabTriageLayout(liveIds, groupProjection, railSortOrder);
const listOrder = triage ? triage.order : railSortOrder;
// Clustered by case: rows are wrapped in one box per case below, and a tab
// in a cluster with company drops the `-<case>` from its name.
const clusterLayout = this._tabClusterLayout(liveIds, groupProjection);
// One row per session, in tab order. The flat strip emits them as-is; the
// grouped rail places the SAME markup into its sections, so a row never
// differs between the two (badge = Alt+N slot in sessionOrder either way).
const rowHtml = new Map();
let _tabIdx = 0;
for (const id of tabOrder) {
const session = this.sessions.get(id);
if (!session) continue; // Skip if session was removed
const railOrderStyle = listOrder?.has(id) ? ` style="order:${listOrder.get(id)}"` : '';
// See the note in the incremental path: a web tab owns the active highlight
// while one is open, even though activeSessionId stays set.
const isActive = id === this.activeSessionId && !this.activeWebviewId;
const status = session.status || 'idle';
const name = this.getSessionName(session);
const mode = session.mode || 'claude';
const color = session.color || 'default';
const taskStats = session.taskStats || { running: 0, total: 0 };
const hasRunningTasks = taskStats.running > 0;
const alertType = this.tabAlerts.get(id);
const alertClass = alertType === 'action' ? ' tab-alert-action' : alertType === 'idle' ? ' tab-alert-idle' : '';
const loadState = this.terminalLoadStates.get(id);
// 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) : '';
// 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) : '';
// Show folder name if session has a custom name AND tall tabs setting is enabled
const folderName = session.workingDir ? session.workingDir.split('/').pop() || '' : '';
const tallTabsEnabled = this._tallTabsEnabled ?? false;
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
// #232: a session with a description (the `: suffix` part of its name) shows
// JUST the description on the tab; the generated w<n>-<case> id moves to the
// tooltip and stays visible in the session settings modal.
const parsedName = parseSessionPrefix(name);
const tabLabel = this._tabNameHtml(name, clusterLayout?.labelFor.get(id));
const tabTooltip = parsedName && parsedName.suffix
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
: (session.workingDir || '');
// Rich rows only (the detailed sidebar OR the vertical tab rail): the home
// screen's created/state stamps and a status pill. richRow is null in every
// other layout, and both helpers below collapse to '' — the header strip's
// markup is unchanged.
const richRow = richRows ? this._sidebarRichRow(id, session) : null;
const richMeta = this._sidebarRichMetaHTML(richRow);
const richClass = richRow ? ` tab-state-${richRow.state}` : '';
const richData = richRow
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}${richRow.exited ? '+exited' : ''}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}:${escapeHtml(richRow.watching)}"`
: '';
// '' whenever the server said nothing about this pane's agent, which covers
// a running pane and every session shape the field never applies to
// (direct-PTY, remote SSH, docker). See paneExitLabel().
const paneExitBadge = paneExitLabel(session.paneExit);
// Which harness runs here. A shell keeps its SH pill (it is not an agent);
// every agent CLI, claude included, shows its logo through PR #532's
// `run-mode-dot <id>` slot, the id as DATA, so the tab, the tile and split
// headers and the Run menus draw the same mark. An id with no logo rule (a
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot.
const tabModeHtml = mode === 'shell'
? '<span class="tab-mode shell" aria-hidden="true">sh</span>'
: `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`;
const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</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">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}${this._tileGrid?.has(id) ? ' in-tiles' : ''}"${richData}${railOrderStyle} 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(paneExitAriaLabel(name, paneExitBadge))}" data-aria-source="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
<span class="tab-name-row">
${tabModeHtml}
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
${paneExitBadge ? `<span class="tab-exited-badge" data-label="${escapeHtml(paneExitBadge)}" aria-hidden="true">${escapeHtml(paneExitBadge)}</span>` : ''}
${inlineSessionActions ? tabActionsHtml : ''}
<span class="tab-detached-badge" aria-hidden="true">detached</span>
</span>
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
${richMeta}
</span>
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
${subagentBadge}
${ultracodeBadge}
${inlineSessionActions ? '' : tabActionsHtml}
</div>`);
_tabIdx++;
}
if (clusterLayout) {
// Clustered by case. Web tabs keep their flat-strip Alt+N slot (after
// every session), each in a box of its own.
parts.push(this._renderTabClusters(clusterLayout, rowHtml, _tabIdx));
this._hiddenTabGroupByRef = new Map();
} else if (groupProjection) {
// Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after
// every session), wherever their group puts them.
const webviewSlots = new Map(
(this.webviewOrder || []).filter((wid) => this.webviews?.has(wid)).map((wid, i) => [wid, _tabIdx + i])
);
parts.push(
window.CodemanTabLayout.renderProjection(
groupProjection,
(ref) =>
ref.kind === 'session'
? rowHtml.get(ref.id) || ''
: this.renderWebviewTab?.(ref.id, webviewSlots.get(ref.id) ?? Infinity) || '',
escapeHtml
)
);
this._hiddenTabGroupByRef = new Map(Object.entries(groupProjection.hiddenTabGroupByRef));
} else {
parts.push(...rowHtml.values());
// Web tabs (dashboard URLs) render after the session tabs, continuing the
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
this._hiddenTabGroupByRef = new Map();
}
this._lastTabGroupStructureKey = this._tabGroupStructureKey(groupProjection);
this._lastTabClusterKey = clusterLayout ? clusterLayout.key : null;
container.innerHTML = parts.join('');
container.classList.toggle('session-tabs--grouped', !!groupProjection);
this._applyTabListRole(container, !!groupProjection);
if (groupProjection) {
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
this._syncTabGroupHeaderAlerts(container, groupProjection);
}
this._syncTabTriageChrome(container, triage);
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
const activeBandMoved = this._noteActiveTabBand(container) && this._isScrollingTabRow(container);
// Put the strip back where the user left it, then reveal the active tab
// only when it CHANGED, or moved to another state band in the scrolling row
// (_noteActiveTabBand), or on the first paint. Restoring unconditionally
// and revealing conditionally is what lets someone browse the far end of
// the strip while a background rebuild fires, without the active tab ever
// being stranded off-screen after a switch.
container.scrollLeft = prevScrollLeft;
container.scrollTop = prevScrollTop;
this._lastRenderedActiveTabId = this.activeSessionId;
if (isFirstRender || prevActiveTabId !== this.activeSessionId || activeBandMoved) {
this._scrollActiveTabIntoView(this.activeSessionId, isFirstRender ? 'auto' : 'smooth');
}
// Set up drag-and-drop handlers for tab reordering
this.setupTabDragHandlers();
// The grouped rail drags with its own pointer model (rows across groups,
// group reorder); bound once, inert unless the rail is grouped.
this._bindTabLayoutPointerDrag(container);
// Set up keyboard navigation for tabs
this.setupTabKeyboardNavigation(container);
// Update connection lines after tabs change (positions may have shifted)
this.updateConnectionLines();
// 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();
// Newly created tabs animate in; a re-render mid-cascade resumes them rather
// than restarting, since this rebuild just destroyed the animating elements.
this._applyTabEntrances?.();
// innerHTML was rebuilt wholesale, so the sidebar filter classes are gone —
// re-apply them or filtered-out sessions flicker back on every SSE tick.
this.applySidebarFilter(this._sidebarFilter);
// Rows that carry self-staling stamps need the clock; rows that don't must
// not leave it running. Both directions matter — the layout can flip
// underneath a render, and a solo window forces 'header' regardless.
if (richRows) this._startSidebarRichClock();
else this._stopSidebarRichClock();
}
// 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) => {
// The grouped rail is a tree with its own key model; everything else
// (header strip, sidebar, flat rail) keeps the tab-strip walk below.
if (container.getAttribute('role') === 'tree') {
this._handleTabTreeKeydown(e, container);
return;
}
// Up/Down are aliases of Left/Right, not replacements: the strip stays
// arrow-key navigable exactly as before, the vertical sidebar just gains
// the axis a user reaches for there.
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' '].includes(e.key)) return;
// Rows hidden by the sidebar filter must not be steppable.
const tabs = [...container.querySelectorAll('.session-tab:not(.tab-filtered-out)')];
// ⚠️ A sorted rail paints its rows with the flex `order` property while the
// DOM stays in `sessionOrder` (that is what keeps the Alt+N badge and the
// drag model honest), so a DOM-order walk steps around the screen instead of
// down it: ArrowDown from the top card lands wherever that session happens to
// sit in the tab order. Walk what the eye sees. Read the COMPUTED order, not
// the inline one, or web tabs (pinned past the cards by a CSS `order: 9999`
// rather than an inline style) read as 0 and the walk starts on them. Array
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
if (this.isTabRailSorted() || container.classList.contains('tabs-triage')) {
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
// Case clusters are boxes in DOM order and a sorted rail orders rows
// INSIDE each one, so the box goes first in the key.
const boxes = [...container.querySelectorAll(':scope > .tab-cluster')];
const boxOf = (el) => boxes.indexOf(el.closest('.tab-cluster'));
tabs.sort((a, b) => boxOf(a) - boxOf(b) || orderOf(a) - orderOf(b));
}
const currentIndex = tabs.indexOf(document.activeElement);
// Enter or Space activates the tab
if ((e.key === 'Enter' || e.key === ' ') && currentIndex >= 0) {
e.preventDefault();
this._activateTabRow(tabs[currentIndex]);
return;
}
if (currentIndex < 0) return;
let newIndex;
switch (e.key) {
case 'ArrowLeft':
case 'ArrowUp':
newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;
break;
case 'ArrowRight':
case 'ArrowDown':
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);
// Grouped tree: a row or header focused by pointer becomes the tab stop, so
// Tab-ing away and back returns to it and there is still exactly one stop.
if (!this._tabTreeFocusinHandler) {
this._tabTreeFocusinHandler = (e) => {
if (container.getAttribute('role') !== 'tree') return;
const item = e.target?.closest?.('[role="treeitem"]');
if (item && container.contains(item)) this._setTabTreeStop(container, item);
};
container.addEventListener('focusin', this._tabTreeFocusinHandler);
}
}
/** Select a session row or open a web-tab row (Enter/Space, either layout). */
_activateTabRow(row) {
if (row?.dataset.webviewId) return this.openWebview(row.dataset.webviewId);
if (row?.dataset.id) return this.selectSession(row.dataset.id, { forceReload: true });
return undefined;
}
// ═══════════════════════════════════════════════════════════════
// Grouped rail: tree semantics and keyboard model
// ═══════════════════════════════════════════════════════════════
//
// Only the GROUPED vertical rail is a tree. #sessionTabs becomes role=tree;
// named-group headers are level-1 treeitems that own their rows (level 2);
// ungrouped rows and a collapsed group's kept selection are level 1. One item
// carries tabindex=0 (roving), every control inside a row is removed from the
// tab order, and focus is restored by identity across full re-renders. The
// flat rail and the header strip keep role=tablist / role=tab untouched.
/** #sessionTabs is a tablist (index.html) except while it holds the grouped tree. */
_applyTabListRole(container, grouped) {
if (grouped) {
container.setAttribute('role', 'tree');
container.setAttribute('aria-label', 'Sessions');
} else if (container.getAttribute('role') === 'tree') {
container.setAttribute('role', 'tablist');
container.setAttribute('aria-label', 'Session tabs');
}
}
/** Stable identity of a tree item (or anything inside one) across re-renders. */
_tabTreeIdentity(element) {
const item = element?.closest?.('[data-tab-group-header], .session-tab');
if (!item) return null;
if (item.dataset.tabGroupHeader) return `group:${item.dataset.tabGroupHeader}`;
if (item.dataset.webviewId) return `webview:${item.dataset.webviewId}`;
if (item.dataset.id) return `session:${item.dataset.id}`;
return null;
}
/**
* Visible tree items in the order the eye reads them: each section's header,
* then its rows. A sorted rail paints rows with the flex `order` property
* inside their own group column, so rows are ordered by COMPUTED order within
* a section (stable, so the unsorted rail keeps DOM order).
*/
_tabTreeItems(container) {
const sorted = this.isTabRailSorted();
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
const items = [];
for (const section of container.querySelectorAll('.tab-layout-group')) {
const header = section.querySelector(':scope > [role="treeitem"]');
if (header) items.push(header);
const rows = [...section.querySelectorAll('.session-tab[role="treeitem"]:not(.tab-filtered-out)')];
if (sorted) rows.sort((a, b) => orderOf(a) - orderOf(b));
items.push(...rows);
}
return items;
}
/** Move the single tab stop to `item` (every other tree item gets -1). */
_setTabTreeStop(container, item) {
for (const el of container.querySelectorAll('[role="treeitem"]')) el.tabIndex = el === item ? 0 : -1;
}
/**
* Turn freshly rendered rows into tree items and place the roving tab stop.
* Rows arrive as the flat strip's markup (role=tab, tabindex=0 on the row and
* its controls); only their semantics change here, never their content.
*/
_applyTabTreeSemantics(container, { identity = null, refocus = false } = {}) {
for (const row of container.querySelectorAll('.session-tab')) {
row.setAttribute('role', 'treeitem');
row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false');
row.setAttribute('aria-level', row.closest('[role="group"]') ? '2' : '1');
// Controls stay clickable, but leave the tab order: the tree has ONE stop,
// and a row's actions are reachable from it with Shift+F10 / ContextMenu.
for (const control of row.querySelectorAll('[tabindex], button, a[href], input, select, textarea')) {
control.tabIndex = -1;
}
}
for (const header of container.querySelectorAll('[data-tab-group-header]')) header.setAttribute('aria-level', '1');
const items = this._applyTabTreePositions(container);
const byIdentity = (id) => (id ? items.find((item) => this._tabTreeIdentity(item) === id) : null);
// A focused row that a collapse just hid hands focus to its group header.
const hiddenIn = identity ? this._hiddenTabGroupByRef?.get(identity) : null;
const target =
byIdentity(identity) ||
(hiddenIn ? byIdentity(`group:${hiddenIn}`) : null) ||
items.find((item) => item.getAttribute('aria-selected') === 'true') ||
items[0];
if (!target) return;
this._setTabTreeStop(container, target);
if (refocus && document.activeElement !== target) target.focus();
}
/**
* aria-posinset / aria-setsize within each level, in PAINTED order: the
* level-1 run (headers, ungrouped rows, a collapsed group's kept row) and each
* group's own rows. Runs after every full render AND after an incremental pass,
* because the activity-sorted rail re-sorts rows in place. Returns the items.
*/
_applyTabTreePositions(container) {
const items = this._tabTreeItems(container);
const sets = new Map();
for (const item of items) {
const owner = item.getAttribute('aria-level') === '2' ? item.closest('[role="group"]') : container;
if (!sets.has(owner)) sets.set(owner, []);
sets.get(owner).push(item);
}
for (const members of sets.values()) {
members.forEach((item, index) => {
const setsize = String(members.length);
const posinset = String(index + 1);
if (item.getAttribute('aria-setsize') !== setsize) item.setAttribute('aria-setsize', setsize);
if (item.getAttribute('aria-posinset') !== posinset) item.setAttribute('aria-posinset', posinset);
});
}
return items;
}
/**
* A collapsed group hides its rows, including ones that need the user. Its
* header takes the most urgent hidden alert in the tab alert language
* (`tab-alert-action` red, `tab-alert-idle` yellow), so a permission prompt
* behind a collapse is never invisible. Patched in place on both render paths:
* alerts change without a rebuild.
*/
_syncTabGroupHeaderAlerts(container, projection) {
const alerts = window.CodemanTabLayout?.hiddenGroupAlerts(projection, (id) => this.tabAlerts?.get(id)) || {};
for (const header of container.querySelectorAll('[data-tab-group-header]')) {
const alert = alerts[header.dataset.tabGroupHeader];
header.classList.toggle('tab-alert-action', alert === 'action');
header.classList.toggle('tab-alert-idle', alert === 'idle');
}
}
/** Keep aria-selected on the grouped tree in step with the .active class. */
_syncTabTreeSelection(container) {
if (container?.getAttribute('role') !== 'tree') return;
for (const row of container.querySelectorAll('.session-tab')) {
row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false');
}
}
/**
* Keyboard model of the grouped tree (WAI-ARIA tree view): Up/Down walk the
* visible items, Home/End jump, Right expands a header or enters it, Left
* collapses a header or climbs from a row to its header, Enter/Space select a
* row or toggle a header, Shift+F10 / ContextMenu open a row's actions.
*/
_handleTabTreeKeydown(e, container) {
if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
const items = this._tabTreeItems(container);
const current = e.target?.closest?.('[role="treeitem"]');
// A key pressed on a control INSIDE a row (its close or overflow button,
// focused by a click or handed focus back by the action menu) belongs to
// that control: Enter there must reopen the menu, not re-select the row.
// Same contract as the flat list, which acts only on a focused row.
if (!current || current !== e.target) return;
const index = items.indexOf(current);
if (index < 0) return;
const groupId = current.dataset.tabGroupHeader || null;
const expanded = current.getAttribute('aria-expanded') === 'true';
// A group with no open rows is a leaf (no aria-expanded): nothing to open.
const expandable = current.hasAttribute('aria-expanded');
const focusAt = (next) => {
if (!next) return;
this._setTabTreeStop(container, next);
next.focus();
};
const toggle = (collapse) => {
// The toggle re-renders the rail; keep focus on this header through it.
this._tabFocusIdentity = `group:${groupId}`;
this.toggleTabGroupCollapsed(groupId, collapse);
};
switch (e.key) {
case 'ArrowDown':
case 'ArrowUp': {
const step = e.key === 'ArrowDown' ? 1 : -1;
focusAt(items[(index + step + items.length) % items.length]);
break;
}
case 'Home':
focusAt(items[0]);
break;
case 'End':
focusAt(items[items.length - 1]);
break;
case 'ArrowRight':
if (!groupId || !expandable) return;
if (!expanded) toggle(false);
else {
const child = items.find((item) => item.closest('[role="group"]')?.id === current.getAttribute('aria-owns'));
if (!child) return;
focusAt(child);
}
break;
case 'ArrowLeft':
if (groupId) {
if (!expandable || !expanded) return;
toggle(true);
} else {
const group = current.closest('[role="group"]');
const header = group ? container.querySelector(`[aria-owns="${CSS.escape(group.id)}"]`) : null;
if (!header) return;
focusAt(header);
}
break;
case 'Enter':
case ' ':
if (groupId) toggle();
else this._activateTabRow(current);
break;
case 'F2':
if (!groupId || !this.startTabGroupRename(groupId)) return;
break;
case 'F10':
case 'ContextMenu': {
if (e.key === 'F10' && !e.shiftKey) return;
const synthetic = { preventDefault() {}, stopPropagation() {}, currentTarget: current };
if (groupId) {
this.openTabGroupMenu(synthetic, groupId);
} else if (current.dataset.id) {
this.openTabRailActionMenu?.(synthetic, current.dataset.id);
} else if (current.dataset.webviewId) {
this.openTabWebviewMenu(synthetic, current.dataset.webviewId);
} else return;
break;
}
default:
return;
}
e.preventDefault();
}
handleSessionTabClick(event, sessionId) {
event?.preventDefault?.();
// Ctrl/Cmd+click puts the session in the tile grid (opening it if needed)
// instead of switching to it; on a window too narrow for the grid it is an
// ordinary click.
if ((event?.ctrlKey || event?.metaKey) && this.addSessionToTiles?.(sessionId)) return;
// 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()) {
document.activeElement?.blur?.();
}
return this.selectSession(sessionId, { forceReload: true });
}
// ═══════════════════════════════════════════════════════════════
// Tab Order and Drag-and-Drop
// ═══════════════════════════════════════════════════════════════
// Sync sessionOrder with current sessions (preserve order for existing, add new at end)
syncSessionOrder() {
const currentIds = new Set(this.sessions.keys());
// Load saved order from localStorage
const savedOrder = this.loadSessionOrder();
// Start with saved order, keeping only sessions that still exist
const preserved = savedOrder.filter(id => currentIds.has(id));
const preservedSet = new Set(preserved);
// Add any new sessions at the end
const newSessions = [...currentIds].filter(id => !preservedSet.has(id));
this.sessionOrder = [...preserved, ...newSessions];
}
// Load session order from localStorage
loadSessionOrder() {
try {
const saved = localStorage.getItem('codeman-session-order');
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
}
// Save session order to localStorage 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.
saveSessionOrder() {
try {
localStorage.setItem('codeman-session-order', JSON.stringify(this.sessionOrder));
} catch {
// Ignore storage errors
}
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();
}
}
// ═══════════════════════════════════════════════════════════════
// Owner tab layout: grouped vertical rail (reading and drawing)
// ═══════════════════════════════════════════════════════════════
//
// The server owns named tab groups (GET /api/tab-layout, tab-layout*.ts) and
// already projects them onto the global session order, so sessionOrder, Alt+N,
// Ctrl+Tab and every other order consumer are untouched here. This layer only
// decides how the VERTICAL rail draws rows: in sections, with per-device
// collapse. With no groups (or any read failure) the rail is the flat list it
// has always been. Editing the groups is the next block.
_ensureTabLayoutCoordinator() {
if (this._tabLayoutCoordinator) return this._tabLayoutCoordinator;
if (!window.CodemanTabLayout) return null;
this._tabLayoutCoordinator = window.CodemanTabLayout.createLoadCoordinator({
fetchLayout: async () => {
const data = await this._apiJson('/api/tab-layout');
if (!data?.layout) throw new Error('Tab layout unavailable');
return data.layout;
},
applyLayout: (layout) => this._applyTabLayout(layout),
applyFallback: () => this._applyTabLayout(null),
// 5 s, 10 s, 20 s, 40 s, then stop until the next SSE init or
// tab:layoutChanged asks again: an unreachable server must not cost a
// fetch every 5 s for as long as the page stays open.
retryDelayMs: 5000,
maxRetryDelayMs: 60000,
maxRetries: 4,
scheduleRetry: (retry, delayMs) => setTimeout(retry, delayMs),
cancelRetry: (timer) => clearTimeout(timer),
});
return this._tabLayoutCoordinator;
}
_loadTabLayout() {
return this._ensureTabLayoutCoordinator()?.load() ?? Promise.resolve(false);
}
/** SSE tab:layoutChanged carries `{ owner, version }`; the layout itself is re-read. */
_onTabLayoutChanged(data) {
const me = window.__codemanUser;
// Another user's layout changed: nothing of ours moved. (The GET is
// owner-scoped server-side, so this is a saved request, not a guard.)
if (me?.multiUser && typeof data?.owner === 'string' && data.owner !== me.username) return;
if (Number.isSafeInteger(data?.version) && this.tabLayout && data.version <= this.tabLayout.version) return;
// Our own write is in flight: its response is the newer truth, and a read
// racing it could repaint the pre-edit layout. Re-read once it settles.
if (this._tabLayoutEditor?.hasPending()) {
this._tabLayoutReloadPending = true;
return;
}
this._loadTabLayout();
}
/** Adopt a layout read (or null after a failed read, which renders flat). */
_applyTabLayout(layout) {
let next = null;
if (layout) {
try {
next = window.CodemanTabLayout.normalizeLayout(layout);
} catch {
next = null;
}
}
// An overtaken response is already dropped by the coordinator; this guards a
// reordering between the coordinator and an SSE-triggered reload.
if (next && this.tabLayout && next.version < this.tabLayout.version) return;
const editor = this._tabLayoutEditor;
if (editor) {
// A failed read says nothing about the layout, and dropping the editor now
// would lose the edit outright: a write in flight would never get its 409
// rebased. Keep the held layout and the editor; read again once it settles.
if (!next && editor.hasPending()) {
this._tabLayoutReloadPending = true;
return;
}
if (next && editor.isWriting()) {
// The write's own response decides; read again after it.
this._tabLayoutReloadPending = true;
return;
}
// Unsaved edits are rebased onto the read (adoptExternal repaints); with
// none, the editor is simply rebuilt from the new layout on next use.
if (next && editor.hasPending() && editor.adoptExternal(next)) return;
if (editor.hasPending()) this.showToast?.('Your tab group edit was not saved.', 'error');
editor.dispose();
this._tabLayoutEditor = null;
}
this.tabLayout = next;
const storage = this._getTabCollapseStorage();
const collapsed = storage && next
? window.CodemanTabLayout.loadCollapsedGroupIds(storage, next.groups.map((group) => group.id))
: { ids: [], ok: !next };
if (!collapsed.ok) this._tabCollapseStorageFailed = true;
this.collapsedTabGroupIds = new Set(collapsed.ids);
// The server announces a layout change on every session create/close, web
// tab create/delete and order PUT, and most of those move nothing on this
// rail (always so on the flat rail, which is every owner without groups).
// Rebuild only when what the rail would draw actually changed.
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
// Edits left unsaved by the previous page (see _persistPendingTabLayoutEdits).
if (next && !this._tabLayoutRestoreChecked) {
this._tabLayoutRestoreChecked = true;
this._restorePendingTabLayoutEdits();
}
}
/** localStorage, or null once it has failed (collapse then stays all-expanded). */
_getTabCollapseStorage() {
if (this._tabCollapseStorageFailed) return null;
try {
return window.localStorage;
} catch {
this._tabCollapseStorageFailed = true;
return null;
}
}
/**
* The grouped projection for the CURRENT render, or null when the rail should
* render flat: horizontal strip (incl. phones and the sidebar, which force it),
* no layout yet, a failed read, or a layout without groups.
*/
_projectTabGroups() {
if (!this.tabLayout || this._tabOrientation() !== 'vertical' || !window.CodemanTabLayout) return null;
return window.CodemanTabLayout.project(this.tabLayout, {
// sessionOrder, not the sessions Map: rows are built in that order, so a
// session the layout has not placed yet lands where the flat strip has it.
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
collapsedGroupIds: [...this.collapsedTabGroupIds],
activeSessionId: this.activeSessionId,
activeWebviewId: this.activeWebviewId,
});
}
_tabGroupStructureKey(projection) {
return window.CodemanTabLayout?.structureKey(this.tabLayout, projection, [...this.collapsedTabGroupIds]) ?? null;
}
/** True when the DOM's grouping no longer matches what a render would draw. */
_isTabGroupStructureStale(projection = this._projectTabGroups()) {
return this._tabGroupStructureKey(projection) !== this._lastTabGroupStructureKey;
}
/**
* Collapse/expand one group (header click). Per-device: stored in localStorage,
* never sent to the server, so collapsing on a laptop leaves the desktop alone.
* A storage failure leaves every group expanded rather than half-remembered.
*/
toggleTabGroupCollapsed(groupId, forceCollapsed) {
if (!this.tabLayout?.groups?.some((group) => group.id === groupId)) return false;
const next = new Set(this.collapsedTabGroupIds);
const shouldCollapse = forceCollapsed === undefined ? !next.has(groupId) : forceCollapsed === true;
if (shouldCollapse) next.add(groupId);
else next.delete(groupId);
const storage = this._getTabCollapseStorage();
const saved = storage
? window.CodemanTabLayout.saveCollapsedGroupIds(storage, [...next])
: { ids: [], ok: false };
if (!saved.ok) this._tabCollapseStorageFailed = true;
this.collapsedTabGroupIds = new Set(saved.ids);
// The full render also redraws connectors anchored to rows that just moved.
this._fullRenderSessionTabs();
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse;
}
// ═══════════════════════════════════════════════════════════════
// Owner tab layout: editing groups from the vertical rail
// ═══════════════════════════════════════════════════════════════
//
// Every edit is a named operation (tab-layout-browser.js) applied to the rail
// at once and saved by ONE serialized PUT /api/tab-layout at a time, with the
// version the server last returned. A 409 is rebased onto the server's layout
// and retried; a failure re-reads. Editing is a vertical-rail feature: the
// header strip, the sidebar and phones never offer it.
_tabLayoutEditable() {
return !!(this.tabLayout && window.CodemanTabLayout && this._tabOrientation() === 'vertical');
}
/** child session id -> parent session id, so a moved session takes the sessions that follow it. */
_tabLayoutParents() {
const parents = {};
for (const session of this.sessions.values()) {
if (session?.parentSessionId && session.parentSessionId !== session.id) parents[session.id] = session.parentSessionId;
}
return parents;
}
async _putTabLayout({ baseVersion, layout }) {
const body = { baseVersion, layout: { ...layout, updatedAt: layout.updatedAt || new Date().toISOString() } };
const response = await this._api('/api/tab-layout', { method: 'PUT', body });
if (!response) return { ok: false, status: 0, layout: null };
let data = null;
try {
data = await response.json();
} catch {}
return { ok: response.ok, status: response.status, layout: data?.data?.layout || null };
}
_ensureTabLayoutEditor() {
if (this._tabLayoutEditor || !this.tabLayout) return this._tabLayoutEditor || null;
this._tabLayoutEditor = window.CodemanTabLayout.createEditCoordinator({
initialLayout: this.tabLayout,
put: (request) => this._putTabLayout(request),
fetchLayout: async () => {
const data = await this._apiJson('/api/tab-layout');
if (!data?.layout) throw new Error('Tab layout unavailable');
return data.layout;
},
applyLayout: (layout) => this._adoptEditedTabLayout(layout),
reportError: (message) => this.showToast?.(message, 'error'),
onFailure: () => {
// The rail may still show an edit the server refused: read the truth.
this._tabLayoutReloadPending = true;
},
onSettled: () => {
if (!this._tabLayoutReloadPending) return;
this._tabLayoutReloadPending = false;
this._loadTabLayout();
},
});
return this._tabLayoutEditor;
}
/** The editor's view of the layout (optimistic or confirmed) becomes the rail. */
_adoptEditedTabLayout(layout) {
this.tabLayout = layout;
const storage = this._getTabCollapseStorage();
if (storage) {
// Forget collapse state for groups that no longer exist.
const collapsed = window.CodemanTabLayout.loadCollapsedGroupIds(storage, layout.groups.map((group) => group.id));
if (collapsed.ok) this.collapsedTabGroupIds = new Set(collapsed.ids);
}
this._fullRenderSessionTabs();
}
/**
* Apply one edit. `focusIdentity` names the tree item that should hold focus
* afterwards (the moved row, the renamed group), so a keyboard user who acted
* from a menu lands back in the rail rather than on <body>.
*/
editTabLayout(operation, focusIdentity) {
if (!this._tabLayoutEditable()) return false;
const active = document.activeElement;
const rail = this.$('sessionTabs');
if (focusIdentity && (!active || active === document.body || rail?.contains(active))) {
this._tabFocusIdentity = focusIdentity;
this._tabRefocusAfterEdit = true;
}
try {
this._ensureTabLayoutEditor().enqueue(operation);
return true;
} catch (error) {
this._tabRefocusAfterEdit = false;
this.showToast?.(error?.message || 'Could not save tab groups.', 'error');
return false;
}
}
_newTabGroupId() {
return globalThis.crypto?.randomUUID?.() || `group-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
/** New group (optionally holding `ref`), then straight into renaming it. */
createTabGroup({ ref = null, index } = {}) {
if (!this._tabLayoutEditable()) return false;
const id = this._newTabGroupId();
const name = window.CodemanI18n?.t?.('New group') || 'New group';
if (!this.editTabLayout({ type: 'createGroup', id, name, ...(Number.isInteger(index) ? { index } : {}) }, `group:${id}`)) {
return false;
}
if (ref) this.editTabLayout({ type: 'moveRef', ref, groupId: id, index: 0, parents: this._tabLayoutParents() });
this.startTabGroupRename(id);
return true;
}
deleteTabGroup(groupId) {
const groups = this.tabLayout?.groups || [];
const index = groups.findIndex((group) => group.id === groupId);
if (index < 0) return false;
if (!window.confirm(`Delete group "${groups[index].name}"? Its tabs move to Ungrouped.`)) {
// The menu that asked is gone; put the keyboard back on the group.
this.$('sessionTabs')?.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`)?.focus();
return false;
}
const neighbour = groups[index + 1] || groups[index - 1];
return this.editTabLayout({ type: 'deleteGroup', groupId }, neighbour ? `group:${neighbour.id}` : null);
}
moveTabGroup(groupId, delta) {
const groups = this.tabLayout?.groups || [];
const from = groups.findIndex((group) => group.id === groupId);
const to = from + delta;
if (from < 0 || to < 0 || to >= groups.length) return false;
return this.editTabLayout({ type: 'reorderGroup', groupId, index: to }, `group:${groupId}`);
}
/** Where a ref is stored: { groupId (null = Ungrouped), refs, index } or null. */
_tabRefLocation(ref) {
const same = (candidate) => candidate.kind === ref.kind && candidate.id === ref.id;
for (const group of this.tabLayout?.groups || []) {
const index = group.refs.findIndex(same);
if (index >= 0) return { groupId: group.id, refs: group.refs, index };
}
const index = this.tabLayout?.ungrouped?.findIndex(same) ?? -1;
return index >= 0 ? { groupId: null, refs: this.tabLayout.ungrouped, index } : null;
}
moveTabRef(ref, groupId, anchor = null, placement = 'before') {
const parents = this._tabLayoutParents();
let destination;
try {
destination = window.CodemanTabLayout.moveDestination(this.tabLayout, ref, groupId, anchor, placement, parents);
} catch {
return false;
}
return this.editTabLayout({ type: 'moveRef', ref, ...destination, parents }, `${ref.kind}:${ref.id}`);
}
/**
* Group placement actions for a row's action menu: reorder within its
* container, move to another group, out to Ungrouped, or into a new group.
* Empty outside the vertical rail, so the header strip's menu is unchanged.
*/
_tabRefMoveActions(ref) {
if (!this._tabLayoutEditable()) return [];
const location = this._tabRefLocation(ref);
if (!location) return [];
const actions = [];
const grouped = this.tabLayout.groups.length > 0;
// Up/down follow the STORED order, which is what the rail paints unless a
// sort is on (then the sort decides and there is nothing to reorder).
if (grouped && !this.isTabRailSorted()) {
// The sessions that follow this one move with it, so "down" means past
// the first row that is not part of that block.
const moving = new Set(window.CodemanTabLayout.movingRefKeys(this.tabLayout, ref, this._tabLayoutParents()));
const previous = location.refs[location.index - 1];
const next = location.refs.slice(location.index + 1).find((candidate) => !moving.has(`${candidate.kind}:${candidate.id}`));
if (previous) actions.push({ label: 'Move up', run: () => this.moveTabRef(ref, location.groupId, previous, 'before') });
if (next) actions.push({ label: 'Move down', run: () => this.moveTabRef(ref, location.groupId, next, 'after') });
}
for (const group of this.tabLayout.groups) {
if (group.id === location.groupId) continue;
// Quoted: a group may be NAMED "New group" or "ungrouped", which unquoted
// would read (and, case-insensitively, translate) exactly like the
// fixed "Move to new group" / "Move to Ungrouped" entries next to it.
actions.push({ label: `Move to "${group.name}"`, run: () => this.moveTabRef(ref, group.id) });
}
if (location.groupId !== null) actions.push({ label: 'Move to Ungrouped', run: () => this.moveTabRef(ref, null) });
// At the server's group cap a new group can only fail, so it is not offered.
if (this._canCreateTabGroup()) actions.push({ label: 'Move to new group', run: () => this.createTabGroup({ ref }) });
return actions;
}
// ─── Group and web-tab menus (right-click, the header's ⋯, Shift+F10) ──
/**
* Close the open group / web-tab menu. Every dismissal path lands here:
* Escape, a pointer outside it, focus leaving it, Tab, a viewport resize, an
* action, and any full re-render of the rail (which would orphan its trigger).
*/
closeTabGroupMenu({ restoreFocus = false } = {}) {
const menu = this._tabGroupMenu;
if (!menu) return;
const trigger = this._tabGroupMenuTrigger;
const identity = this._tabGroupMenuKey;
this._tabGroupMenu = null;
this._tabGroupMenuTrigger = null;
this._tabGroupMenuKey = null;
document.removeEventListener('pointerdown', this._tabGroupMenuOutside, true);
document.removeEventListener('keydown', this._tabGroupMenuKeydown, true);
window.removeEventListener('resize', this._tabGroupMenuResize);
this._tabGroupMenuOutside = this._tabGroupMenuKeydown = this._tabGroupMenuResize = null;
menu.remove();
if (!restoreFocus) return;
// The trigger may have been re-rendered while the menu was open; find the
// live tree item by identity.
const rail = this.$('sessionTabs');
const item =
(trigger?.isConnected && trigger.closest('[role="treeitem"]')) ||
[...(rail?.querySelectorAll('[role="treeitem"]') || [])].find((el) => this._tabTreeIdentity(el) === identity);
item?.focus();
}
_canCreateTabGroup() {
const max = window.CodemanTabLayout?.MAX_GROUPS;
return !max || (this.tabLayout?.groups?.length || 0) < max;
}
openTabGroupMenu(event, groupId) {
const groups = this.tabLayout?.groups || [];
const index = groups.findIndex((group) => group.id === groupId);
if (index < 0) return false;
return this._openTabLayoutMenu(event, `group:${groupId}`, 'Group actions', [
...(this.canOpenTileGrid?.() ? [{ label: 'Open group as tiles', run: () => this.openGroupAsTiles(groupId) }] : []),
{ label: 'Rename group', run: () => this.startTabGroupRename(groupId) },
...(this._canCreateTabGroup() ? [{ label: 'New group', run: () => this.createTabGroup({ index: index + 1 }) }] : []),
...(index > 0 ? [{ label: 'Move group up', run: () => this.moveTabGroup(groupId, -1) }] : []),
...(index < groups.length - 1 ? [{ label: 'Move group down', run: () => this.moveTabGroup(groupId, 1) }] : []),
{ label: 'Delete group', className: 'danger', run: () => this.deleteTabGroup(groupId) },
]);
}
/** Keyboard actions for a web-tab row in the vertical rail: its settings plus group moves. */
openTabWebviewMenu(event, webviewId) {
const moves = this._tabRefMoveActions({ kind: 'webview', id: webviewId });
if (!moves.length) {
this.showWebviewModal?.(webviewId);
return false;
}
return this._openTabLayoutMenu(event, `webview:${webviewId}`, 'Web tab actions', [
{ label: 'Web tab settings', run: () => this.showWebviewModal?.(webviewId) },
...moves,
]);
}
_openTabLayoutMenu(event, identity, ariaLabel, actions) {
event?.preventDefault?.();
event?.stopPropagation?.();
const trigger = event?.currentTarget || null;
// Opening the same menu again closes it (a toggle, like the row menu).
if (this._tabGroupMenu && this._tabGroupMenuKey === identity) {
this.closeTabGroupMenu();
return false;
}
this.closeTabGroupMenu();
this.closeTabRailActionMenu?.();
if (!this._tabLayoutEditable()) return false;
const menu = document.createElement('div');
menu.className = 'tab-rail-action-menu tab-layout-group-action-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', ariaLabel);
for (const action of actions) {
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('role', 'menuitem');
button.textContent = action.label;
if (action.className) button.className = action.className;
button.addEventListener('click', () => {
this.closeTabGroupMenu();
action.run();
});
menu.appendChild(button);
}
document.body.appendChild(menu);
const anchor = (trigger?.getBoundingClientRect ? trigger : null) || this.$('sessionTabs');
const rect = anchor?.getBoundingClientRect?.() || { left: 8, bottom: 8, right: 8 };
const menuRect = menu.getBoundingClientRect();
const left = event?.clientX && event.type === 'contextmenu' ? event.clientX : rect.left;
menu.style.left = `${Math.max(8, Math.min(left, window.innerWidth - menuRect.width - 8))}px`;
menu.style.top = `${Math.max(8, Math.min(rect.bottom + 4, window.innerHeight - menuRect.height - 8))}px`;
this._tabGroupMenu = menu;
this._tabGroupMenuTrigger = trigger;
this._tabGroupMenuKey = identity;
this._tabGroupMenuOutside = (pointerEvent) => {
if (menu.contains(pointerEvent.target) || (trigger && trigger.contains?.(pointerEvent.target))) return;
this.closeTabGroupMenu();
};
// Capture on document, so Escape closes THIS menu and nothing else (the
// global Escape handler defers to it, see the keydown listener in init).
this._tabGroupMenuKeydown = (keyEvent) => {
if (keyEvent.key !== 'Escape') return;
keyEvent.preventDefault();
keyEvent.stopImmediatePropagation();
this.closeTabGroupMenu({ restoreFocus: true });
};
this._tabGroupMenuResize = () => this.closeTabGroupMenu();
document.addEventListener('pointerdown', this._tabGroupMenuOutside, true);
document.addEventListener('keydown', this._tabGroupMenuKeydown, true);
window.addEventListener('resize', this._tabGroupMenuResize);
menu.addEventListener('keydown', (keyEvent) => {
const buttons = [...menu.querySelectorAll('button')];
const at = buttons.indexOf(document.activeElement);
if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowUp') {
keyEvent.preventDefault();
buttons[(at + (keyEvent.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length]?.focus();
} else if (keyEvent.key === 'Home' || keyEvent.key === 'End') {
keyEvent.preventDefault();
buttons[keyEvent.key === 'Home' ? 0 : buttons.length - 1]?.focus();
} else if (keyEvent.key === 'Tab') {
// Tab would walk out and leave the popup on screen: dismiss to the row.
keyEvent.preventDefault();
this.closeTabGroupMenu({ restoreFocus: true });
}
});
// Focus leaving by any other route (a click elsewhere, a programmatic move).
// Hops between the menu's own items are not a departure.
menu.addEventListener('focusout', (focusEvent) => {
if (focusEvent.relatedTarget && menu.contains(focusEvent.relatedTarget)) return;
if (this._tabGroupMenu === menu) this.closeTabGroupMenu();
});
menu.querySelector('button')?.focus();
return true;
}
// ─── Inline group rename ───────────────────────────────────────────
/**
* Rename a group in place. Shares the session rename's ownership handle
* (`_activeRename`), so starting one cancels the other and only the CURRENT
* editor may release the render guard. Enter or blur commits, Escape cancels,
* IME composition keys belong to the IME. The commit goes through the edit
* coordinator, so it is serialized behind any write already in flight.
*/
startTabGroupRename(groupId) {
if (!this.tabLayout?.groups?.some((candidate) => candidate.id === groupId)) return false;
// Cancelling another editor re-renders the rail, so look the header up after.
this._activeRename?.cancel();
const group = this.tabLayout?.groups?.find((candidate) => candidate.id === groupId);
const header = this.$('sessionTabs')?.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
const label = header?.querySelector('.tab-layout-group-name');
if (!group || !label) return false;
this._inlineRenameActive = true;
const input = document.createElement('input');
input.type = 'text';
input.className = 'tab-layout-group-rename-input';
input.value = group.name;
input.maxLength = 60;
input.setAttribute('aria-label', 'Group name');
label.classList.add('tab-layout-group-name--renaming');
label.replaceChildren(input);
// The header toggles collapse on click and opens its menu on right-click;
// neither may fire from inside the editor.
for (const type of ['click', 'contextmenu', 'pointerdown']) input.addEventListener(type, (e) => e.stopPropagation());
let settled = false;
const handle = { groupId, cancel: () => settle(false) };
// `fromBlur`: focus already moved somewhere the user chose (the terminal,
// another control). Pulling it back to the header from inside the blur
// handler wins over that move, so a blur commit never asks for refocus.
const settle = (commit, { fromBlur = false } = {}) => {
if (settled) return;
settled = true;
const name = input.value.trim();
// Only the current editor owns the guard: a newer rename keeps it.
if (this._activeRename !== handle) return;
this._activeRename = null;
this._inlineRenameActive = false;
const current = this.tabLayout?.groups?.find((candidate) => candidate.id === groupId);
const focusIdentity = fromBlur ? null : `group:${groupId}`;
if (commit && current && name && name !== current.name) {
if (this.editTabLayout({ type: 'renameGroup', groupId, name }, focusIdentity)) return;
}
if (focusIdentity) {
this._tabFocusIdentity = focusIdentity;
this._tabRefocusAfterEdit = true;
}
this._fullRenderSessionTabs();
};
this._activeRename = handle;
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter') {
e.preventDefault();
settle(true);
} else if (e.key === 'Escape') {
e.preventDefault();
settle(false);
}
});
input.addEventListener('blur', () => settle(true, { fromBlur: true }));
input.focus();
input.select();
return true;
}
// ─── Pointer drag in the grouped rail ──────────────────────────────
/**
* Drag rows between groups and reorder groups, in the GROUPED rail only.
* Pointer Events (mouse and pen; touch keeps scrolling the rail), bound once
* on the container, which survives every re-render. The flat rail and the
* header strip keep the HTML5 drag in setupTabDragHandlers() untouched.
* Keyboard equivalents live in the row and group menus.
*/
_bindTabLayoutPointerDrag(container) {
if (!container || container._tabLayoutDragBound) return;
container._tabLayoutDragBound = true;
container.addEventListener('pointerdown', (e) => this._onTabLayoutPointerDown(e, container));
container.addEventListener('pointermove', (e) => this._onTabLayoutPointerMove(e, container));
container.addEventListener('pointerup', (e) => this._finishTabLayoutPointerDrag(e, container));
container.addEventListener('pointercancel', () => this._cancelTabLayoutPointerDrag(container));
container.addEventListener('lostpointercapture', () => this._cancelTabLayoutPointerDrag(container));
}
_onTabLayoutPointerDown(e, container) {
// A press whose release never reached us (let go outside the rail, or
// outside the window) must not survive into this one: a stale pending
// press turned into a phantom drag on the next hover, and a replaced drag
// left its Escape listener behind for good.
if (this._tabLayoutDrag) this._cancelTabLayoutPointerDrag(container);
if (e.button !== 0 || e.pointerType === 'touch' || !container.classList.contains('session-tabs--grouped')) return;
if (this._inlineRenameActive || !this._tabLayoutEditable()) return;
// Controls keep their own click; only the row body or the header drags.
if (e.target.closest('.tab-actions, .tab-badge, .tab-layout-group-menu, button, input, [onclick*="stopPropagation"]')) return;
const header = e.target.closest('[data-tab-group-header]');
const row = header ? null : e.target.closest('.session-tab');
let source = null;
if (header) source = { type: 'group', groupId: header.dataset.tabGroupHeader };
else if (row?.dataset.webviewId) source = { type: 'ref', ref: { kind: 'webview', id: row.dataset.webviewId } };
else if (row?.dataset.id) source = { type: 'ref', ref: { kind: 'session', id: row.dataset.id } };
if (!source) return;
const drag = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, source, origin: header || row, active: false, target: null };
// Capture is only taken once the press becomes a drag, so until then the
// release can land outside the rail: hear it on window.
drag.windowUp = (upEvent) => this._finishTabLayoutPointerDrag(upEvent, container);
drag.windowCancel = () => this._cancelTabLayoutPointerDrag(container);
window.addEventListener('pointerup', drag.windowUp, true);
window.addEventListener('pointercancel', drag.windowCancel, true);
this._tabLayoutDrag = drag;
}
/** What a pointer at (x, y) would drop onto, from the rendered rail. */
_tabLayoutDropTarget(x, y, container) {
const hit = document.elementFromPoint(x, y);
if (!hit || !container.contains(hit)) return null;
const row = hit.closest('.session-tab');
const section = hit.closest('.tab-layout-group');
const sectionGroup = section ? section.dataset.tabGroupId || null : undefined;
// A sorted rail paints its own order, so a row can only be dropped INTO a
// group, never between two rows.
if (row && section && !this.isTabRailSorted()) {
const ref = row.dataset.webviewId ? { kind: 'webview', id: row.dataset.webviewId } : { kind: 'session', id: row.dataset.id };
const rect = row.getBoundingClientRect();
return { type: 'ref', ref, groupId: sectionGroup, placement: y >= rect.top + rect.height / 2 ? 'after' : 'before', element: row };
}
if (sectionGroup === undefined) return null;
const element = section.querySelector(':scope > .tab-layout-group-header');
return sectionGroup === null ? { type: 'ungrouped', element } : { type: 'group', groupId: sectionGroup, element };
}
_clearTabLayoutDropMarks(container) {
container.querySelectorAll('.tab-layout-drop-before, .tab-layout-drop-after, .tab-layout-drop-into').forEach((el) =>
el.classList.remove('tab-layout-drop-before', 'tab-layout-drop-after', 'tab-layout-drop-into')
);
}
_onTabLayoutPointerMove(e, container) {
const drag = this._tabLayoutDrag;
if (!drag || drag.pointerId !== e.pointerId) return;
// The primary button is up, so the release went somewhere we never heard.
if ((e.buttons & 1) === 0) {
this._cancelTabLayoutPointerDrag(container);
return;
}
if (!drag.active) {
if (Math.hypot(e.clientX - drag.x, e.clientY - drag.y) < 6) return;
drag.active = true;
drag.origin.classList.add('tab-layout-dragging');
container.classList.add('tab-layout-drag-active');
this.closeTabGroupMenu();
this.closeTabRailActionMenu?.();
try {
container.setPointerCapture(e.pointerId);
} catch {}
if (this._tabLayoutDragKeydown) document.removeEventListener('keydown', this._tabLayoutDragKeydown, true);
this._tabLayoutDragKeydown = (keyEvent) => {
if (keyEvent.key !== 'Escape') return;
keyEvent.preventDefault();
keyEvent.stopImmediatePropagation();
this._cancelTabLayoutPointerDrag(container);
};
document.addEventListener('keydown', this._tabLayoutDragKeydown, true);
}
e.preventDefault();
const target = this._tabLayoutDropTarget(e.clientX, e.clientY, container);
this._clearTabLayoutDropMarks(container);
drag.target = target;
if (!target?.element) return;
const cls = target.type === 'ref' && drag.source.type === 'ref' ? `tab-layout-drop-${target.placement}` : 'tab-layout-drop-into';
target.element.classList.add(cls);
}
_cancelTabLayoutPointerDrag(container) {
const drag = this._tabLayoutDrag;
if (!drag) return;
this._tabLayoutDrag = null;
if (drag.windowUp) window.removeEventListener('pointerup', drag.windowUp, true);
if (drag.windowCancel) window.removeEventListener('pointercancel', drag.windowCancel, true);
drag.origin?.classList.remove('tab-layout-dragging');
container?.classList.remove('tab-layout-drag-active');
if (container) this._clearTabLayoutDropMarks(container);
if (this._tabLayoutDragKeydown) document.removeEventListener('keydown', this._tabLayoutDragKeydown, true);
this._tabLayoutDragKeydown = null;
if (drag.active) {
// The click that ends a drag must not also select the row or toggle the header.
const swallow = (clickEvent) => {
clickEvent.stopPropagation();
clickEvent.preventDefault();
};
window.addEventListener('click', swallow, { capture: true, once: true });
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0);
}
}
_finishTabLayoutPointerDrag(e, container) {
const drag = this._tabLayoutDrag;
if (!drag || drag.pointerId !== e.pointerId) return;
const target = drag.active ? this._tabLayoutDropTarget(e.clientX, e.clientY, container) || drag.target : null;
this._cancelTabLayoutPointerDrag(container);
if (!target) return;
const operation = window.CodemanTabLayout.dropOperation(this.tabLayout, drag.source, target, this._tabLayoutParents());
if (!operation) return;
const identity = drag.source.type === 'group' ? `group:${drag.source.groupId}` : `${drag.source.ref.kind}:${drag.source.ref.id}`;
this.editTabLayout(operation, identity);
}
// ─── Unsaved edits across a reload ─────────────────────────────────
/**
* The page is going away with edits not yet confirmed: send them with a
* keepalive PUT (it outlives the page) AND keep a copy in sessionStorage. If
* the keepalive lands, the copy replays to no change after reload; if it lost
* a race, the copy is rebased onto the fresh layout and saved properly.
*/
_persistPendingTabLayoutEdits() {
const editor = this._tabLayoutEditor;
const operations = editor?.pendingOperations?.() || [];
if (!operations.length) return false;
try {
sessionStorage.setItem(
'codeman:tab-layout-pending',
JSON.stringify({ owner: this._tabLayoutOwnerKey(), baseVersion: editor.baseVersion(), savedAt: Date.now(), operations })
);
} catch {}
try {
const layout = editor.getLayout();
void fetch('/api/tab-layout', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseVersion: editor.baseVersion(), layout: { ...layout, updatedAt: layout.updatedAt || new Date().toISOString() } }),
keepalive: true,
}).catch(() => {});
} catch {}
return true;
}
/** Whose layout this page edits, as the server keys it (`@single` without multi-user). */
_tabLayoutOwnerKey() {
const me = window.__codemanUser;
if (!me) return null;
return me.multiUser ? me.username : '@single';
}
/**
* Replay the previous page's unsaved edits, but only that page's: the copy is
* ignored when it belongs to another owner (a different login in this tab),
* is older than a reload could explain, or names a layout newer than the one
* just read (a different server behind the same origin).
*/
_restorePendingTabLayoutEdits() {
let saved;
try {
const raw = sessionStorage.getItem('codeman:tab-layout-pending');
if (!raw) return false;
saved = JSON.parse(raw);
} catch {
return false;
}
const owner = this._tabLayoutOwnerKey();
if (owner === null) {
// Who we are is not known yet (/api/me still loading): decide once it is.
if (!this._tabLayoutRestoreWaiting) {
this._tabLayoutRestoreWaiting = true;
document.addEventListener(
'codeman:me',
() => {
this._tabLayoutRestoreWaiting = false;
this._restorePendingTabLayoutEdits();
},
{ once: true }
);
}
return false;
}
try {
sessionStorage.removeItem('codeman:tab-layout-pending');
} catch {}
const operations = saved?.operations;
if (!Array.isArray(operations) || !operations.length || !this.tabLayout || !window.CodemanTabLayout) return false;
if (saved.owner !== owner) return false;
const age = Date.now() - saved.savedAt;
if (!Number.isFinite(age) || age < 0 || age > TAB_LAYOUT_PENDING_MAX_AGE_MS) return false;
if (!Number.isSafeInteger(saved.baseVersion) || saved.baseVersion > this.tabLayout.version) return false;
return this._ensureTabLayoutEditor().restore(operations);
}
// Set up drag-and-drop handlers on tab elements
setupTabDragHandlers() {
const container = this.$('sessionTabs');
const tabs = container.querySelectorAll('.session-tab[data-id]');
// A self-sorting list cannot also be hand-ordered: the drop below rewrites
// sessionOrder correctly, the sort then puts the card straight back where it
// was, and the user is left dragging a row that refuses to move. Drop the
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
// order this list is no longer showing.
// The grouped rail opts out too: a flat-order drag cannot express "move
// into that group", and the server would re-rank it within its old group
// anyway. It has its own pointer drag (_bindTabLayoutPointerDrag).
if (this.isTabRailSorted() || container.classList.contains('session-tabs--grouped')) {
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
return;
}
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) => {
// Grouped by state: a tab in another group is not a drop target, and
// leaving the event alone (no preventDefault) is what shows "no drop".
if (this._isTabDropAcrossGroups(tab)) return;
e.preventDefault();
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
e.dataTransfer.dropEffect = 'move';
// Determine drop position based on mouse position. Read the layout here,
// inside the handler — these listeners survive a layout flip between
// renders, so capturing the axis at bind time would go stale.
// drag-over-left/-right keep their names and now read as before/after;
// the sidebar/rail CSS just draws them as top/bottom edges.
const rect = tab.getBoundingClientRect();
const insertBefore = this._isVerticalTabList()
? e.clientY < rect.top + rect.height / 2
: e.clientX < rect.left + rect.width / 2;
// Update visual indicator
tab.classList.toggle('drag-over-left', insertBefore);
tab.classList.toggle('drag-over-right', !insertBefore);
});
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;
if (this._isTabDropAcrossGroups(tab)) return;
const targetId = tab.dataset.id;
const draggedId = this.draggedTabId;
// Determine insertion position (same axis rule as the dragover handler)
const rect = tab.getBoundingClientRect();
const insertBefore = this._isVerticalTabList()
? e.clientY < rect.top + rect.height / 2
: e.clientX < rect.left + rect.width / 2;
// 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();
});
});
}
/**
* Grouped rail: Ctrl+Shift+{ / } may only swap the active session with a
* neighbour in its OWN section. Across a group boundary the server re-ranks
* each group on its own (`putLegacyOrder`), so nothing moves there, no
* session:orderChanged comes back, and this client would keep a swapped
* sessionOrder (and Alt+N targets) that no other device shares. Same reason
* the HTML5 flat-order drag is off in the grouped rail (its own pointer drag
* and the row menu's moves go through moveRef instead, which can cross a
* group). Any other layout: always allowed.
*/
_canSwapActiveTabWith(neighbourId) {
const projection = this._projectTabGroups();
if (!projection) return true;
const sectionOf = (id) => projection.sectionByRef[`session:${id}`];
return sectionOf(this.activeSessionId) === sectionOf(neighbourId);
}
moveActiveTabLeft() {
if (!this.activeSessionId) return;
const idx = this.sessionOrder.indexOf(this.activeSessionId);
if (idx <= 0) return;
if (!this._canSwapActiveTabWith(this.sessionOrder[idx - 1])) 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;
if (!this._canSwapActiveTabWith(this.sessionOrder[idx + 1])) return;
[this.sessionOrder[idx], this.sessionOrder[idx + 1]] = [this.sessionOrder[idx + 1], this.sessionOrder[idx]];
this.saveSessionOrder();
this._fullRenderSessionTabs();
}
// ═══════════════════════════════════════════════════════════════
// Session Lifecycle — select, close, navigate
// ═══════════════════════════════════════════════════════════════
getShortId(id) {
if (!id) return '';
let short = this._shortIdCache.get(id);
if (!short) {
short = id.slice(0, 8);
this._shortIdCache.set(id, short);
}
return short;
}
getSessionName(session) {
// Use custom name if set
if (session.name) {
return session.name;
}
// Fall back to directory name
if (session.workingDir) {
return session.workingDir.split('/').pop() || session.workingDir;
}
return this.getShortId(session.id);
}
_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,
});
}
/**
* 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.
*/
_isUsableXtermSnapshot(snapshot) {
if (!snapshot || typeof snapshot !== 'string' || snapshot.length < 8) return false;
const visibleText = snapshot
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
.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;
}
/**
* 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 */
}
}
// `skipSnapshot`: the tile grid opening on the session it parks
// (tile-grid.js openTileGrid), whose snapshot closing the grid discards.
_cleanupPreviousSession(newSessionId, { skipSnapshot = false } = {}) {
// 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.
// 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 (
!skipSnapshot &&
this.activeSessionId &&
outgoingSession?.mode !== 'shell' &&
this._serializeAddon &&
this._xtermSnapshots
) {
try {
const snapshot = this._serializeAddon.serialize({ scrollback: 1000 });
if (this._isUsableXtermSnapshot(snapshot)) {
// 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);
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,
// 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.
if (snapshot.length < 256 * 1024) {
this._persistXtermSnapshot(`codeman-xs-${this.activeSessionId}`, snapshot);
}
} else {
this._xtermSnapshots.delete(this.activeSessionId);
try { localStorage.removeItem(`codeman-xs-${this.activeSessionId}`); } catch {}
}
} catch (_err) {
/* Serialize failed — fall back to server buffer replay */
}
}
// Close WebSocket for previous session (new one opens after buffer load)
this._disconnectWs();
// 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 = '';
}
// Clean up flicker filter state when switching sessions
this._clearTimer('flickerFilterTimeout');
this.flickerFilterBuffer = '';
this.flickerFilterActive = false;
// Clear tab completion detection flag — don't carry across sessions
this._tabCompletionSessionId = null;
this._tabCompletionRetries = 0;
this._tabCompletionBaseText = null;
this._clearTimer('_tabCompletionFallback');
this._clearTimer('_clientDropRecoveryTimer');
this._terminalRefreshOwner = null;
// Clean up pending terminal writes to prevent old session data from appearing in new session
this._clearTimer('syncWaitTimeout');
this.pendingWrites = [];
this.writeFrameScheduled = false;
// Release the one-chunk-in-flight gate with the rest of the write queue.
// flushPendingWrites() early-returns while this is set, so a reset that
// cleared everything EXCEPT this flag would leave live output permanently
// stalled if xterm's parse callback never lands (disposed terminal, or a
// throw inside the async parse). A late callback is harmless: it clears an
// already-clear flag and schedules a flush.
this._terminalWriteInFlight = false;
this._terminalWriteInFlightBytes = 0;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._bufferLoadOwner = null;
// 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;
// End any in-flight IME composition.
// iOS Safari keeps autocorrect composing; switching tabs without ending it
// leaves xterm's _compositionHelper._isComposing stuck true, which blocks
// keyboard input when the user returns to this tab.
try {
const ch = this.terminal?._core?._compositionHelper;
if (ch?._isComposing) {
ch._isComposing = false;
// Also fire compositionend on the textarea so any other listeners reset
const ta = this.terminal?.element?.querySelector('.xterm-helper-textarea');
if (ta) ta.dispatchEvent(new CompositionEvent('compositionend', { data: '' }));
}
} catch {}
this._flushLocalEchoTo(this.activeSessionId);
this._localEchoOverlay?.clear();
// Predictions are ephemeral + already sent: nothing to save/restore
// across a tab switch (unlike the buffer overlay's setFlushed machinery)
this._predictiveEcho?.clearPredictions();
// Prevent _detectBufferText() from picking up Claude's Ink UI text
// (status bar, model info, etc.) as "user input" on fresh sessions.
// Only sessions with prior flushed text (from tab-switch-away) need detection.
// After the user's first Enter, clear() resets _bufferDetectDone = false,
// re-enabling detection for tab completion and other legitimate cases.
if (this._localEchoOverlay && !this._flushedOffsets?.has(newSessionId)) {
this._localEchoOverlay.suppressBufferDetection();
}
}
/**
* Hand the local-echo overlay's unsent text to `sessionId` before anything
* clears it, and record what has now been flushed so `_render()` offsets the
* overlay correctly even before the PTY echo comes back.
*
* On a touch device the characters the user has typed live ONLY here until
* Enter — they have never reached the PTY — so whoever clears the overlay
* owes them a flush first. It is sent as one batch with no Enter, so it lands
* in the session's readline buffer rather than submitting a line the user has
* not finished.
*
* ⚠️ The session is a PARAMETER because the two callers are looking at
* different ones. `_cleanupPreviousSession` flushes to the tab being left,
* which is still `activeSessionId` when it runs. The `forceReload` branch in
* `selectSession` flushes to the tab being RELOADED, and must do it before it
* nulls `activeSessionId`: reading the field after that null is what silently
* dropped the text, since the guard here then saw no session and the
* unconditional `clear()` that follows took the characters with it.
* @param {string|null} sessionId
*/
_flushLocalEchoTo(sessionId) {
if (!sessionId) return;
const echoText = this._localEchoOverlay?.pendingText || '';
// Include buffer-detected flushed text (from Tab completion, etc.)
// so it's preserved across tab switches.
const existingFlushed = this._localEchoOverlay?.getFlushed()?.count || 0;
const existingFlushedText = this._localEchoOverlay?.getFlushed()?.text || '';
if (echoText) {
this._sendInputAsync(sessionId, echoText);
}
const totalOffset = existingFlushed + echoText.length;
if (totalOffset > 0) {
if (!this._flushedOffsets) this._flushedOffsets = new Map();
if (!this._flushedTexts) this._flushedTexts = new Map();
this._flushedOffsets.set(sessionId, totalOffset);
this._flushedTexts.set(sessionId, existingFlushedText + echoText);
}
}
/**
* Clear the terminal for a replay, IN STREAM.
*
* xterm's `write()` is asynchronously queued (the WriteBuffer parses in ~12ms
* slices) while `Terminal.reset()` is synchronous and, by upstream's own
* documentation, "does not clear input buffers and does not reset the parser,
* thus the terminal will continue to apply pending input data". So bytes
* queued just before a `reset()` are parsed AFTER it and fuse into whatever
* snapshot is written next — measured upstream as `p8rmissions` rendered
* where `bypass permissions` belonged.
*
* A queued clear cannot race that way: it lands after the leftovers and
* before the snapshot, whatever the queue held. This function used to follow
* the sync `reset()` with a queued `\x1b[3J\x1b[H\x1b[2J`, which already got
* that right for CONTENT. RIS (`\x1bc`) additionally resets modes, charsets,
* scroll regions and SGR state, so leftover bytes cannot park the terminal in
* alt-screen or an odd scroll region and survive the clear.
*
* Callers may write the replacement content in as many chunks as they like —
* ordering within the queue is what matters, not writing it all at once.
*/
_resetTerminalForReplay() {
this.terminal.write('\x1bc');
}
_recordTerminalLoadTiming(timing) {
this._lastTerminalLoadTiming = timing;
console.info('[TERMINAL-PERF]', timing);
const resetAndParseMs =
(timing.cacheResetAndParseMs || 0) +
(timing.freshResetAndParseMs || 0) +
(timing.resetAndParseMs || 0);
const totalMs = timing.selectDoneMs ?? timing.totalMs ?? timing.selectToReplayCompleteMs ?? 0;
_crashDiag.log(
`TERMINAL_LOAD: ${timing.trigger} ${timing.full ? 'full' : 'tail'} ${timing.chars} chars ` +
`ttfb=${timing.ttfbMs.toFixed(0)}ms body+json=${timing.bodyAndJsonMs.toFixed(0)}ms ` +
`reset+parse=${resetAndParseMs.toFixed(0)}ms total=${totalMs.toFixed(0)}ms ` +
`server="${timing.serverTiming}"${timing.refused ? ' refused-downgrade' : ''}`
);
}
/**
* "Load more history": re-pull the whole tmux scrollback when the user scrolls up
* while already at the top of what the browser has.
*
* xterm's buffer is only ever a WINDOW onto tmux's real history, and two things
* shrink it. tmux repaints the pane rectangle instead of emitting linefeeds
* whenever output outpaces its flush interval, which OVERWRITES already-rendered
* scrollback rather than pushing rows into it (measured: a 60-line burst added 1
* row and destroyed 34, while the same 60 lines emitted slowly added all 60). And
* a tab switch replays only the visible frame. Either way tmux still holds
* everything (history-limit 100k by default), so the fix is to go ask for it with
* the same `?full=1` capture a page reload uses (issue #205).
*
* On demand rather than automatic because that capture is unbounded-ish work: at
* the default history limit it can be megabytes, which is fine to pay when the
* user is explicitly reaching for history and not fine on every tab switch.
*
* NEVER a downgrade: for a repaint-mode CLI pane tmux keeps no history of its
* own, so the capture can be THINNER than what xterm already holds and the
* reset+rewrite below would delete history mid-scroll. `_replayWouldShrinkBuffer`
* (terminal-ui.js) is the guard, and a session that produced one useless re-pull
* gets a much longer cooldown so a hollow pane stops re-fetching megabytes on
* every scroll-up (issue #205, round 2).
*/
async _maybeRefetchFullHistory({ force = false } = {}) {
const sessionId = this.activeSessionId;
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
if (this.detachedSessions?.has(sessionId)) return;
// The parked main terminal has no history to pull while tiles own the screen.
if (this._tilesOwnTerminal?.()) return;
const session = this.sessions.get(sessionId);
// A shell's full capture can be many megabytes, and replaying all of it from
// an ordinary scroll gesture blocks xterm's main thread. So a shell scroll
// pulls a BOUNDED window of tmux's full history (the same 1 MiB a tab switch
// loads, but of the scrollback rather than the visible frame) and the
// unbounded pull stays behind the "Load full history" button. Declining
// outright left a shell pane about one screen of browser scrollback after any
// burst, and the button only renders once a replay was truncated, so a young
// shell tab had no way back to output tmux was still holding.
const boundedShellPull = !force && session?.mode === 'shell';
const now = Date.now();
// Momentum scrolling fires this dozens of times per flick, and a burst of new
// output is the normal reason to want a re-pull, so cooldown rather than latch.
// `force` is the user pressing "Load full history" (#258): they asked once,
// explicitly, so the scroll-gesture cooldown does not apply. The downgrade
// guard below still does — a forced pull must not destroy history either.
const cooldown = this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000;
if (!force && now - (this._fullHistoryRepullAt.get(sessionId) || 0) < cooldown) return;
this._fullHistoryRepullAt.set(sessionId, now);
this._fullHistoryRepullInFlight = true;
try {
const requestStartedAt = performance.now();
const capture = await this._fetchTerminalCapture(
boundedShellPull
? `/api/sessions/${sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`
: `/api/sessions/${sessionId}/terminal?full=1`,
{ full: true }
);
const headersReceivedAt = capture.headersAt;
const payload = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
const buffer = payload.terminalBuffer;
const timing = {
trigger: force ? 'full-history-button' : 'full-history-scroll',
mode: session?.mode || 'unknown',
full: true,
source: payload.source || 'unknown',
chars: buffer?.length || 0,
ttfbMs: headersReceivedAt - requestStartedAt,
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
resetAndParseMs: 0,
totalMs: 0,
serverTiming: capture.headers?.get?.('server-timing') || '',
refused: false,
};
// Bail on a tab switch mid-fetch: writing here would paint another session's
// history into the terminal the user is now looking at.
if (!buffer || this.activeSessionId !== sessionId) return;
const windowRows = this._estimateReplayRows(buffer, this.terminal.cols);
// A bounded window no longer than the browser's buffer buys nothing, and
// resetting to rewrite it would jump the viewport on every scroll that
// outlasts the cooldown at the top. This runs BEFORE the downgrade guard
// on purpose: that guard reads "smaller than the browser" as "tmux has
// nothing more to give", which is true of an unbounded capture but not of a
// window cut at the tail size, so a bounded window must never reach the
// exhausted path, which would take Load full history off the banner while
// tmux still holds the rest. Nothing was written here, so the banner state
// is left as the load that produced it set it: re-labelling it from this
// payload would call a terminal that holds ALL of a Load full history pull
// "the most recent 1 MiB".
//
// A browser already at xterm's cap buys nothing either. xterm keeps at most
// `scrollback + rows` rows (DEFAULT_SCROLLBACK 50k) while tmux keeps 100k
// lines by default, so a 1 MiB window of short lines can render to more rows
// than the browser can ever hold, and `windowRows <= rowsNow` then never
// comes true: without this every scroll-to-top would reset and re-parse it.
const rowsNow = this.terminal.buffer.active.length;
const scrollbackCap = this.terminal.options?.scrollback || 0;
const browserFull = scrollbackCap > 0 && rowsNow >= scrollbackCap + this.terminal.rows;
if (boundedShellPull && (windowRows <= rowsNow || browserFull)) {
// An untruncated window IS all of tmux's history, so nothing is missing,
// and the next burst of output can put more in tmux than the browser has:
// keep the normal 4 s cooldown. A truncated one is the opposite case, since
// the gesture can never reach anything older than what the browser already
// shows, and every ask costs the server a synchronous capture-pane of the
// whole history (`tail` is applied after the capture): back off to 60 s.
// A full browser backs off too, since no window can ever fit in it.
// Trade-off: only a successful replay clears that latch, so a tab switch or
// burst that shrinks the browser's buffer below the window can leave a
// scroll-to-top inert for up to a minute. Load full history (`force`)
// bypasses the cooldown, and the latch is bounded, never permanent.
if (payload.truncated || browserFull) (this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
this._logScrollRouting?.('repull-skipped-bounded');
return;
}
if (this._replayWouldShrinkBuffer(buffer, windowRows)) {
timing.refused = true;
timing.totalMs = performance.now() - requestStartedAt;
this._recordTerminalLoadTiming(timing);
(this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
this._logScrollRouting?.('repull-refused-downgrade');
// The browser already holds more than tmux can give back, so there is
// nothing further to offer and the indicator must stop promising it.
this._setHistoryTruncation(sessionId, { ...payload, exhausted: true });
return;
}
// A bounded window that was cut is always recoverable: a capture over the
// byte cap keeps `truncationReason: 'capped'` through the tail cut, and that
// would tell the user the rest "cannot be recovered" and drop Load full
// history, whose unbounded pull returns up to the cap itself.
this._setHistoryTruncation(
sessionId,
boundedShellPull && payload.truncated ? { ...payload, truncationReason: 'tail' } : payload
);
this._fullHistoryRepullUseless?.delete(sessionId);
const rowsBefore = this.terminal.buffer.active.length;
const replayStartedAt = performance.now();
this._resetTerminalForReplay();
const {
parsedAt,
bufferLength: parsedBufferLength,
completed,
} = await this.chunkedTerminalWrite(
buffer,
TERMINAL_CHUNK_SIZE,
sessionId,
this._bufferLoadFinishOpts(payload, headersReceivedAt)
);
timing.resetAndParseMs = parsedAt - replayStartedAt;
if (!completed || this.activeSessionId !== sessionId) return;
// Keep shell tab restores bounded too. A user-triggered full-history pull
// may be tens of MB; caching it would replay that whole payload again on
// the next tab switch before the normal 1MB tail fetch replaces it.
if (this.sessions.get(sessionId)?.mode !== 'shell') {
this.terminalBufferCache.set(sessionId, buffer);
} else {
this.terminalBufferCache.delete(sessionId);
}
// Hold the user's place. The replay is a superset that grew the buffer
// UPWARD, so what used to be row 0 (what they were looking at) is now `delta`
// rows down; scrolling there reveals the recovered history above it instead
// of teleporting them to the bottom the way a normal buffer load does.
const delta = parsedBufferLength - rowsBefore;
if (delta > 0) this.terminal.scrollToLine(delta);
else this.terminal.scrollToTop();
// The load's own replay sampled the sticky-scroll baseline while the
// terminal sat at the bottom of a just-rewritten buffer, so the next
// flush would scroll back down and undo the restore above. This path is
// reached only from a scroll-up gesture, so being dragged down is the
// exact opposite of what the user asked for.
this._syncStickyScrollBaseline();
timing.totalMs = performance.now() - requestStartedAt;
this._recordTerminalLoadTiming(timing);
} catch {
// Transient (offline, 5xx) — the next scroll-up past the cooldown retries.
} finally {
this._fullHistoryRepullInFlight = false;
}
}
/**
* Record how much history a replay actually carried, and refresh the banner.
*
* Called from every path that writes a fetched buffer into xterm. Keyed by
* session because the banner describes the ACTIVE tab and a background fetch
* must not relabel it.
*/
_setHistoryTruncation(sessionId, payload = {}) {
if (!sessionId) return;
(this._historyTruncation ||= new Map()).set(sessionId, {
truncated: !!payload.truncated,
reason: payload.truncationReason ?? null,
source: payload.source ?? null,
fullSize: payload.fullSize ?? 0,
retainedBytes: payload.retainedBytes ?? 0,
// Set once a full-history pull has been refused as a downgrade: the
// browser holds more than the server can return, so there is no more.
exhausted: !!payload.exhausted,
});
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
}
/** Drop banner state for a session that is going away. */
_clearHistoryTruncation(sessionId) {
this._historyTruncation?.delete(sessionId);
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
}
/**
* Paint the partial-history banner for the active session.
*
* Three distinct states, because "we tailed for speed" and "the oldest output
* is gone forever" are not the same message and the old single boolean could
* not tell them apart:
* - recoverable → offer to load the rest
* - exhausted → say so plainly, offer nothing
* - at the limit → the full capture ITSELF hit the byte ceiling
*/
_renderHistoryTruncationBanner() {
const bar = document.getElementById('historyTruncationBar');
if (!bar) return;
const state = this.activeSessionId ? this._historyTruncation?.get(this.activeSessionId) : null;
const notice = computeHistoryTruncationNotice(state || {});
if (!notice.visible) {
bar.hidden = true;
return;
}
bar.textContent = '';
const label = document.createElement('span');
label.className = 'history-trunc-text';
label.textContent = notice.message;
bar.appendChild(label);
if (notice.canLoadMore) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'history-trunc-load';
btn.textContent = 'Load full history';
btn.onclick = () => {
btn.disabled = true;
btn.textContent = 'Loading…';
// Forced: the cooldown exists to throttle scroll gestures, not choices.
this._maybeRefetchFullHistory({ force: true }).finally(() => {
this._renderHistoryTruncationBanner();
});
};
bar.appendChild(btn);
}
const dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.className = 'history-trunc-dismiss';
dismiss.setAttribute('aria-label', 'Dismiss history notice');
dismiss.textContent = '×';
dismiss.onclick = () => {
bar.hidden = true;
};
bar.appendChild(dismiss);
bar.hidden = false;
}
_shouldFocusTerminalForTabSwitch() {
if (typeof MobileDetection === 'undefined' || !MobileDetection.isTouchDevice()) {
return true;
}
return typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
}
async selectSession(sessionId, options = {}) {
// Picking another tab yourself retires a `#session=<id>` link still
// waiting for its session, which would otherwise take the tab from you
// whenever that session turned up (see _selectUrlSession).
if (options?.auto !== true && this._urlSessionId && this._urlSessionId !== sessionId) {
this._retireUrlSession();
}
// If this session is popped out into its own window, raise that window
// 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.
if (!this.isSoloWindow && this.detachedSessions.has(sessionId)) {
if (this._raiseDetached(sessionId)) return;
}
const forceReload = options?.forceReload === true;
// ⚠️ `auto: true` marks a selection the APP made rather than the human:
// the boot restore, a solo window opening its target, a `#session=<id>`
// link from another page, the fallback after the active session is
// deleted. Those must NOT spend a pending idle alert (the yellow survives
// until a real tap), because "the app put this on screen" is not "I
// checked it". The DEFAULT is user-initiated, so a call site nobody tagged
// fails toward acknowledging rather than toward an alert that can never be
// cleared.
const userInitiated = options?.auto !== true;
if (this.activeSessionId === sessionId && !forceReload) {
// Tapping the tab you are already on is still "I checked it". The alert
// can be armed on the ACTIVE tab (a live idle_prompt fires regardless of
// which tab is showing, and so does the reload seed), and every other
// clear path runs on the switch this early return skips, leaving a
// yellow tab that no tap could clear.
if (userInitiated) this.markIdleAlertSeen(sessionId);
return;
}
// Tile grid open (tile-grid.js): a tiled session is FOCUSED in its tile and
// never loaded into the parked main terminal. Decision 1: only a USER-
// initiated pick of a session that is not tiled (or an explicit
// `leaveTiles`, a followed link) leaves the grid for the single view, the
// grid remembered for one-click return. An app-driven pick (`auto`) never
// collapses it.
if (this._tileGrid?.open) {
if (this._tileGrid.has(sessionId)) return this._selectTiledSession(sessionId, options);
if (options?.auto === true && !options?.leaveTiles) return;
this.closeTileGrid({ keepStored: true, reselect: false });
// The parked terminal still holds what it showed before the grid opened;
// with no active id, the switch below snapshots none of it.
this.activeSessionId = null;
}
if (this.activeSessionId === sessionId && forceReload) {
this.terminalBufferCache?.delete(sessionId);
this._xtermSnapshots?.delete(sessionId);
try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
this._clearTimer('syncWaitTimeout');
this.pendingWrites = [];
this.writeFrameScheduled = false;
this._terminalWriteInFlight = false;
this._terminalWriteInFlightBytes = 0;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._terminalRefreshOwner = null;
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
// Anything typed but not yet submitted lives in the local-echo overlay and
// has never reached the PTY. `_cleanupPreviousSession` below flushes it,
// but only for a session it can still see, and the null on the next line
// hides this one from it. Flush first or the characters are cleared
// unread. The geometry replay re-enters here with no gesture behind it,
// so on a touch device this fires while the user is still typing.
this._flushLocalEchoTo(sessionId);
this.activeSessionId = null;
}
// 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.
// 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();
if (shouldFocusTerminal && this.terminal) this.terminal.focus();
const _selStart = performance.now();
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
_crashDiag.log(`SELECT: ${_selName}`);
console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
const selectGen = ++this._selectGeneration;
this._setTerminalLoadState(sessionId, selectGen, 'resizing');
if (selectGen !== this._selectGeneration) {
this._clearTerminalLoadState(sessionId, selectGen);
return; // newer tab switch won
}
// A session tab takes the stage back from any active web tab.
this._hideWebviewLayer?.();
this._cleanupPreviousSession(sessionId);
this.activeSessionId = sessionId;
this._activateFileBrowserSession?.(sessionId);
// Repaint the partial-history banner for the tab being switched TO. The
// replay paths refresh it when their fetch lands; without this the previous
// session's notice stays on screen until then (#258).
this._renderHistoryTruncationBanner();
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
// 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);
this.hideWelcome();
// Terminal-pane entrance: plays for a freshly created session, and on every
// switch when that option is on. Transform/opacity/clip-path only, xterm's
// FitAddon reads the untransformed layout box, so this cannot reach the PTY.
this.playTerminalEntrance?.(sessionId);
// Clear idle hooks on view, but keep action hooks until user interacts.
// Also acknowledged server-side, so the yellow does not come back on the
// next reload and the user's other devices clear it too. Skipped for an
// `auto` selection (see userInitiated above).
if (userInitiated) this.markIdleAlertSeen(sessionId);
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
this._updateActiveTabImmediate(sessionId);
// Handheld: the session drawer overlays the terminal, so slide it away now
// that a session has been picked. No-op on desktop and in header layout.
this.closeSessionSidebarOnHandheld();
this.renderSessionTabs();
this.updateAttachmentHistoryBadge?.();
if (this.attachmentHistoryDrawerOpen) {
this.loadAttachmentHistory?.(sessionId);
}
this._updateLocalEchoState();
// Shell sessions get the terminal keyboard bar, agent sessions the command
// bar (issue #262). Also disarms a one-shot Ctrl left over from the tab we
// just left, so it can never fire against the session we just opened.
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.refreshForActiveSession();
// Remote-host reachability banner: only meaningful for a remote session, so this
// also clears it when the newly active tab is local.
this.refreshHostWakeBanner?.(sessionId);
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
// the async buffer load. Without this, the offset is 0 during the
// fetch() gap: backspace is swallowed, and typing a space covers the
// canvas text with an opaque overlay showing only the new char.
if (this._flushedOffsets?.has(sessionId) && this._localEchoOverlay) {
this._localEchoOverlay.setFlushed(
this._flushedOffsets.get(sessionId),
this._flushedTexts?.get(sessionId) || '',
false // render=false: buffer not loaded yet
);
}
// Glow the newly-active tab
const activeTab = document.querySelector(`.session-tab.active[data-id="${sessionId}"]`);
if (activeTab) {
activeTab.classList.add('tab-glow');
activeTab.addEventListener('animationend', () => activeTab.classList.remove('tab-glow'), { once: true });
}
// Check if this is a restored session that needs to be attached
const session = this.sessions.get(sessionId);
// Track working directory for path normalization in Project Insights
this.currentSessionWorkingDir = session?.workingDir || null;
if (session && session.pid === null) {
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);
}
}
}
// Hold for the terminal font before measuring anything. A cell measured
// against a fallback font gives the wrong column and row count, and the
// correction would land after the replay, leaving the CLI drawing against a
// frame the terminal no longer shows. Resolves immediately once the font is
// in, so this costs a tab switch nothing after the first load, and it is
// bounded, so a font that never arrives cannot strand the session.
// ⚠️ BEFORE `_beginBufferLoad` on purpose: inside it, every live SSE event
// for this session queues instead of painting, so a slow font would hold
// output back rather than merely mis-measuring the grid.
if (this._terminalFontReady) {
await this._terminalFontReady;
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
}
// Load terminal buffer for this session
// Show cached content instantly while fetching fresh data in background.
// Use tail mode for faster initial load (128KB is enough for recent visible content).
//
// Protect flushed state during buffer load: terminal.write() can trigger
// xterm.js onData responses (DA, OSC, etc.) that would otherwise clear
// the flushed Maps via the control char handler. The multi-byte ESC
// filter catches most cases, but _restoringFlushedState provides a
// belt-and-suspenders guard for any edge cases.
this._restoringFlushedState = true;
// Gate live SSE terminal writes for the ENTIRE buffer load sequence.
// Without this, SSE events arriving during the fetch() gap compete with
// the buffer write, causing 70KB+ single-frame flushes that stall WebGL.
// chunkedTerminalWrite also sets this, but we need it before the fetch too.
const bufferLoadOwner = this._beginBufferLoad(selectGen);
// 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;
let cacheResetAndParseMs = 0;
// Hoisted out of the try: the catch needs to know whether the pane was
// blanked before the fetch, because only then is there nothing on screen.
let clearedBeforeFresh = false;
try {
// 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.
// A width refusal belonged to the PREVIOUS session's pane, and while it
// stands sendResize keeps the columns it last adopted; this pane's own
// report re-establishes it if another device holds this one too.
this._paneWidthRefused = false;
this.syncTerminalGeometry();
// 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.
const dimsChanged = await this.sendResize(sessionId, { forceHttp: true }).catch(() => false);
// The size the capture below will be taken against. The debounced resize
// handler can move the terminal again while the load runs, so this is a
// recorded value rather than a later read of `_lastResizeDims`.
const dimsAtCapture = this.getTerminalDimensions?.();
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
// 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/antigravity
// 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;
// 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);
this._xtermSnapshots?.set(sessionId, persisted);
} else if (persisted) {
localStorage.removeItem(`codeman-xs-${sessionId}`);
}
} catch (_e) {
/* localStorage unavailable — proceed without snapshot */
}
}
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
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;
}
// 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.
const cachedBuffer = this.terminalBufferCache.get(sessionId);
// `clearedBeforeFresh` is declared above the try, because the catch reads
// it — re-declaring it here would shadow that and silently break it.
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot && session?.mode !== 'shell') {
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
const cacheReplayStartedAt = performance.now();
this._resetTerminalForReplay();
const { parsedAt: cacheParsedAt } = await this.chunkedTerminalWrite(
cachedBuffer,
TERMINAL_CHUNK_SIZE,
bufferLoadOwner
);
cacheResetAndParseMs = cacheParsedAt - cacheReplayStartedAt;
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
this.terminal.scrollToBottom();
_crashDiag.log('CACHE_DONE');
} else if (sessionIsBusy || session?.mode === 'shell') {
// Busy sessions have stale caches. Shell sessions deliberately skip even
// an idle cache so a changed 1MB tail cannot cause two back-to-back parses.
this._resetTerminalForReplay();
clearedBeforeFresh = true;
_crashDiag.log(session?.mode === 'shell' ? 'CACHE_SKIP_SHELL' : 'CACHE_SKIP_BUSY');
}
// Give TUI sessions a short chance to redraw after resize before the
// 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));
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
}
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
_crashDiag.log('FETCH_START');
// TUI sessions still get one canonical full replay per page (COD-47/#205).
// A shell can retain hundreds of thousands of plain scrollback lines, so
// automatically replaying all of them makes tab selection scale with the
// entire session. Load its bounded 1MB tail first; the existing truncation
// banner action fetches ?full=1 when the user explicitly asks for it.
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
const fetchStartedAt = performance.now();
const tailUrl = `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`;
let capture;
try {
capture = await this._fetchTerminalCapture(
useFullHistory ? `/api/sessions/${sessionId}/terminal?full=1` : tailUrl,
{ full: useFullHistory }
);
} catch (err) {
// The deadline made a slow link reachable for the first time, and the
// pane was already blanked above — so an abort here used to leave a
// black rectangle, discard the queued live output, and never reach
// `_connectWs`. Degrade to the bounded tail instead: less history, but
// a working tab. Only for the full-history pull; the tail has nothing
// smaller to fall back to, and a second failure is the honest floor.
if (err?.name !== 'AbortError' || !useFullHistory) throw err;
_crashDiag.log('FULL CAPTURE ABORTED → tail');
// It never loaded, so the next select must be allowed to try again.
this._fullHistoryLoaded.delete(sessionId);
capture = await this._fetchTerminalCapture(tailUrl);
}
const headersReceivedAt = capture.headersAt;
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
const data = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
// How this load must end, decided here because `chunkedTerminalWrite` is
// what actually ends it for a non-empty buffer. A tmux pane capture is a
// point-in-time frame, so nothing that reached the browser after the
// response headers can already be in it. Replay exactly that tail;
// discarding it drops the CLI's output for the rest of the load window,
// and its next partial redraw then lands on a frame the terminal never
// received. `since` keeps the pre-capture events dropped, because the
// capture does hold those and replaying them would duplicate output.
const finishOpts = this._bufferLoadFinishOpts(data, headersReceivedAt);
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
let freshResetAndParseMs = 0;
if (data.terminalBuffer) {
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
// very visible, causing the terminal to flash blank then repaint.
// 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 || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer;
if (needsRewrite) {
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
const replayStartedAt = performance.now();
this._resetTerminalForReplay();
// Truncation is reported OUT OF BAND (#258). This used to write a grey
// "... earlier output truncated ..." line into the
// terminal itself, which scrolls away with the output it describes,
// cannot be actioned, and is indistinguishable from real CLI output.
this._setHistoryTruncation(sessionId, data);
// Use chunked write for large buffers to avoid UI jank
const { parsedAt: freshParsedAt } = await this.chunkedTerminalWrite(
data.terminalBuffer,
TERMINAL_CHUNK_SIZE,
bufferLoadOwner,
finishOpts
);
freshResetAndParseMs = freshParsedAt - replayStartedAt;
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
// Ensure terminal is scrolled to bottom after buffer load
this.terminal.scrollToBottom();
}
// Shell selection always uses a fresh bounded tail, so retaining its
// payload only wastes memory and can evict useful TUI caches.
if (session?.mode === 'shell') {
this.terminalBufferCache.delete(sessionId);
} else {
// Update cache (cap at 20 entries)
this.terminalBufferCache.set(sessionId, data.terminalBuffer);
if (this.terminalBufferCache.size > 20) {
// Evict oldest entry (first key in Map iteration order)
const oldest = this.terminalBufferCache.keys().next().value;
this.terminalBufferCache.delete(oldest);
}
}
} else if (!cachedBuffer || clearedBeforeFresh) {
// Nothing was painted. If this path was not already cleared above,
// clear stale content now; either way queued live output must be flushed.
if (!clearedBeforeFresh) this._resetTerminalForReplay();
bufferWasEmpty = true;
}
const terminalLoadTiming = {
trigger: 'session-select',
mode: session?.mode || 'unknown',
full: useFullHistory,
source: data.source || 'unknown',
chars: data.terminalBuffer?.length || 0,
ttfbMs: headersReceivedAt - fetchStartedAt,
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
cacheResetAndParseMs,
freshResetAndParseMs,
selectToReplayCompleteMs: performance.now() - _selStart,
serverTiming: capture.headers?.get?.('server-timing') || '',
};
// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
// _finishBufferLoad after ordering the fetched snapshot in xterm; 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.
if (this._isLoadingBuffer) {
// Only reached when the write was skipped. COD-144 lives here: a new
// session's first prompt exists only as a queued event that predates the
// response, so an empty paint replays its queue WHOLE rather than from
// the header timestamp.
this._finishBufferLoad(
bufferLoadOwner,
bufferWasEmpty ? { flushQueued: true, since: 0 } : finishOpts
);
}
// This load repainted the session from the server, so any pending
// output-gap marker is already satisfied. Selecting a session runs BEFORE
// _connectWs, so without this the socket opening afterwards would replay
// the whole buffer again on top of the one just written.
this._markTerminalBufferReconciled(sessionId);
// Drop the guard so user input clears state normally
this._restoringFlushedState = false;
// Restore flushed offset and text for this session so the overlay positions
// correctly even before the PTY echo arrives in the terminal buffer.
if (this._flushedOffsets?.has(sessionId) && this._localEchoOverlay) {
this._localEchoOverlay.setFlushed(
this._flushedOffsets.get(sessionId),
this._flushedTexts?.get(sessionId) || '',
false // render=false: buffer just loaded, defer to rerender
);
// Trigger render after xterm.js finishes processing the buffer data.
// terminal.write('', callback) fires the callback after ALL previously
// queued writes have been parsed — so findPrompt() can find ❯ in the buffer.
const zl = this._localEchoOverlay;
this.terminal.write('', () => {
if (zl.hasPending) zl.rerender();
});
}
// Fire-and-forget resize 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);
// sendResize fits synchronously before its first await, so this reads the
// size that survived the load rather than the one the capture was taken
// at. The two differ whenever the terminal was still settling.
const dimsAfterLoad = this.getTerminalDimensions?.();
// Only a visible-frame capture positions its rows absolutely, and only
// that frame can be damaged by a terminal of the wrong size. A `full=1`
// body is linear scrollback closed by a RELATIVE cursor move
// (`formatCursorRestore`), which is relative precisely so the browser's
// row count need not match the pane's, and a `history` body is the byte
// stream, which carries no row alignment to protect. Replaying either at
// a different size repairs nothing, and the full-history replay costs a
// second whole-scrollback capture to learn that. Since the first select
// of every non-shell session per page takes the full-history path, an
// ungated comparison fires most often on the one response it cannot help.
const framePositionsRowsAbsolutely = data.source === 'mux-visible';
// `mux-visible` is necessary but not sufficient: when the `display-message`
// cursor query fails, `capturePaneBuffer` skips the snapshot repaint and
// returns the raw capture, and the route still labels a non-empty body
// `mux-visible`. That body positions nothing and reports no geometry, so a
// size that moved during such a load has nothing to repair, and replaying
// would buy a second capture, a reset plus chunked rewrite, a dropped
// WebSocket and a discarded xterm snapshot for it. The two comparisons
// below already stand down on an absent field; this one has to as well.
const sizeMovedUnderLoad =
framePositionsRowsAbsolutely &&
Number.isFinite(data.captureRows) &&
!!dimsAtCapture &&
!!dimsAfterLoad &&
(dimsAfterLoad.cols !== dimsAtCapture.cols || dimsAfterLoad.rows !== dimsAtCapture.rows);
// A capture positions every row absolutely, so a pane taller than this
// terminal writes its overflow rows onto the last line and loses the rows
// it overwrote. A pane WIDER than this terminal damages the same frame a
// second way: `formatPaneSnapshot` paints each row out to the pane's own
// width, so a narrower browser wraps every painted row, and the wrap on
// the last one scrolls the whole frame up by a row. Both happen when the
// capture wins a race against the resize meant to precede it, which is
// what the retry below repairs.
//
// It also happens when `Session.resize` DECLINED the resize, which it does
// for a small viewport while a desktop viewport's size claim is live. The
// retry cannot repair that one: it re-sends the same declined resize and
// captures the same too-tall pane. `resizeRetry` stops it after the one
// extra attempt, and the frame is shown as-is. Repairing that case means
// changing who owns the pane size, which is a policy question this does
// not touch. What the flag does buy there is that the client can SEE the
// mismatch at all, which it previously could not.
//
// An ABSENT field is not a fit. It means the capture reported no geometry
// at all, so nothing was positioned and there is nothing to repair.
const capturedTallerThanTerminal =
framePositionsRowsAbsolutely &&
Number.isFinite(data.captureRows) &&
data.captureRows > (this.terminal?.rows || 0);
const capturedWiderThanTerminal =
framePositionsRowsAbsolutely &&
Number.isFinite(data.captureCols) &&
data.captureCols > (this.terminal?.cols || 0);
// The retry replays at `dimsAfterLoad`, so it can only change what is on
// screen if the pane was drawing at some OTHER size. When the reported
// geometry already IS that size, the second pass captures the identical
// frame and pays a full reload to do it: another fetch, another
// `_resetTerminalForReplay()` and chunked rewrite (a visible re-flash),
// and, because it goes through `forceReload`, a dropped and reopened
// WebSocket plus a deleted xterm snapshot.
//
// A race never produces this equality: its whole premise is that the pane
// was still at the size we asked it to leave. So the equality means the
// pane already IS what we asked for and a retry would capture the same
// frame twice.
//
// ⚠️ This used to also be the signature of a CLAMP, and that is now fixed
// at the source rather than worked around here (issue #464).
// `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` did
// not, so a terminal under 40 columns or 10 rows reported a pane
// permanently bigger than itself and every select retried without ever
// converging. `syncTerminalGeometry()` now applies that floor to xterm as
// well, so the browser terminal IS the size it reports and the clamp can
// no longer manufacture a mismatch — which also means the repair below is
// reached only by cases it can actually repair.
//
// The other non-converging case, `Session.resize` declining a small
// viewport while a desktop claim is live, does not produce this equality
// either — that pane sits at the DESKTOP's size. It no longer needs
// repairing from here: the server reports the geometry the PTY actually
// holds ({"t":"zc"} / the resize response) and `_onPtyGeometryReport`
// adopts it, so the terminal matches the pane that is being drawn instead
// of replaying against one that never existed.
const captureMatchesRequestedSize =
!!dimsAfterLoad && data.captureCols === dimsAfterLoad.cols && data.captureRows === dimsAfterLoad.rows;
// 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(() => this._refreshSessionPanels(sessionId, selectGen));
// Open WebSocket for low-latency terminal I/O (after buffer load completes)
this._connectWs(sessionId);
_crashDiag.log('FOCUS');
if (shouldFocusTerminal && this.terminal) this.terminal.focus();
this.scrollToLastNonEmptyLine();
// 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();
}
const selectDoneMs = performance.now() - _selStart;
terminalLoadTiming.selectDoneMs = selectDoneMs;
this._recordTerminalLoadTiming(terminalLoadTiming);
this._clearTerminalLoadState(sessionId, selectGen);
_crashDiag.log(`SELECT_DONE: ${selectDoneMs.toFixed(0)}ms`);
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${selectDoneMs.toFixed(0)}ms`);
// Remember whether the replay was worth it, because `resizeRetry` only
// caps the recursion INSIDE one select and says nothing about the next
// one. A pane this browser cannot size — one whose resize `Session.resize`
// declines while a desktop claim is live, or one a second tmux client is
// also holding — reports the same mismatch on every select, so without a
// memo the diagnosis is paid for again on every tab switch, forever: two
// fetches per select rather than one. Each extra pass costs a second
// `capture-pane`, which is `execSync` and blocks the server's event loop,
// plus a reset and chunked rewrite, a discarded snapshot and cache entry,
// and a dropped and reopened WebSocket.
//
// A retry pass that STILL does not fit is the proof, since the retry ran
// at the size that stuck and the pane ignored it. Geometry that fits
// clears the memo, so a pane that becomes sizeable again (the desktop tab
// closes, the claim goes idle) is repaired on the next select. The race
// case is untouched: it converges on its first attempt, so it never
// reaches the branch that latches.
const capturedGeometryFits =
framePositionsRowsAbsolutely &&
Number.isFinite(data.captureRows) &&
!capturedTallerThanTerminal &&
!capturedWiderThanTerminal;
if (capturedGeometryFits) {
this._geometryRetryUseless?.delete(sessionId);
} else if (options?.resizeRetry && (capturedTallerThanTerminal || capturedWiderThanTerminal)) {
(this._geometryRetryUseless ||= new Set()).add(sessionId);
}
// What is on screen was drawn for a geometry this terminal does not have.
// Replaying once against the size that stuck is the only thing that
// repairs it: SIGWINCH reaches the CLI only on a real size change, and
// the pane is already at its final size, so no redraw is coming.
// `resizeRetry` caps this at one attempt, so two competing fits cannot
// trade replays forever.
if (
(sizeMovedUnderLoad || capturedTallerThanTerminal || capturedWiderThanTerminal) &&
!captureMatchesRequestedSize &&
!this._geometryRetryUseless?.has(sessionId) &&
!options?.resizeRetry &&
!this._isStaleSelect(selectGen)
) {
_crashDiag.log(
`RESIZE_RETRY: capture ${data.captureCols}x${data.captureRows} vs terminal ` +
`${this.terminal?.cols}x${this.terminal?.rows}` +
(sizeMovedUnderLoad ? ' (size moved under load)' : '')
);
// Re-arm the full-history pull ONLY if this pass actually used one, so
// the retry replays the same content at the geometry that stuck. A pass
// that took the bounded tail must retry on the tail too: clearing the
// flag unconditionally would UPGRADE a tab switch into a fresh
// multi-megabyte scrollback capture it never asked for.
//
// UNREACHABLE as written, and kept for the invariant rather than the
// branch. A `useFullHistory` pass sends `full=1`, and the route answers
// `full=1` with `mux-full-history` or `history`, never `mux-visible`
// (see the source ladder in session-routes.ts), so the gate above
// already rules out every pass that consumed the flag. Do not read this
// line as evidence that a page load retries: it does not, and the test
// suite pins that it does not.
if (useFullHistory) this._fullHistoryLoaded.delete(sessionId);
await this.selectSession(sessionId, { auto: true, forceReload: true, resizeRetry: true });
}
} catch (err) {
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
this._restoringFlushedState = false;
console.error('Failed to load session terminal:', err);
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
// The history did not load. That is not a reason to leave the tab dead:
// ⚠️ the socket is what carries LIVE output, and it is opened at the end
// of the happy path, so bailing here left the session mute until the user
// switched away and back.
this._connectWs(sessionId);
// Only when the pane was blanked for a replay that never came. A pane
// still holding its previous content is stale, not empty, and stacking a
// notice on top of readable output is worse than the staleness.
if (clearedBeforeFresh && this.terminal) {
// Three short lines, none over 25 columns, because the narrowest
// terminal this app will render is the 40-column floor and a notice
// that wraps there leaves a lone '.' on a line of its own — measured at
// 320px, where a single 52-character sentence did exactly that.
// Each line is one fact: what failed, that the session is still alive,
// and what to do. The last says RELOAD rather than "reopen the tab",
// because `selectSession` early-returns when the session is already
// active, so clicking the tab you are already on retries nothing.
this.terminal.write(
'\r\n\x1b[2m History did not load.\r\n Live output continues.\r\n Reload to try again.\x1b[0m\r\n'
);
}
// ⚠️ CLEAR, not 'failed'. `_setTerminalLoadState` only marks the TAB, and
// nothing ever cleared it on this path — so the tab kept its spinner and
// `aria-busy="true"` forever, telling every reader and every screen reader
// that a load was still running when it had already given up.
this._clearTerminalLoadState(sessionId, selectGen);
}
}
/**
* The panels that follow the active session (respawn banner and countdown,
* action log, task panel, Ralph state, CLI info, project insights, subagent
* window visibility, file browser). Run deferred, after the terminal content
* is on screen, by selectSession and by the tile grid's focus change
* (tile-grid.js _selectTiledSession), so both share one copy.
*
* @param {string} sessionId - the session that just became active
* @param {number} selectGen - the `_selectGeneration` of that selection; a
* newer one (the user switched again) makes this a no-op
*/
_refreshSessionPanels(sessionId, selectGen) {
// A newer selection won: the user switched tabs again.
if (selectGen !== this._selectGeneration) return;
// Update respawn banner
if (this.respawnStatus[sessionId]) {
this.showRespawnBanner();
this.updateRespawnBanner(this.respawnStatus[sessionId].state);
document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0;
this.updateCountdownTimerDisplay();
this.updateActionLogDisplay();
if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) {
this.startCountdownInterval();
}
} else {
this.hideRespawnBanner();
this.stopCountdownInterval();
}
// Update task panel if open
const taskPanel = document.getElementById('taskPanel');
if (taskPanel && taskPanel.classList.contains('open')) {
this.renderTaskPanel();
}
// Update ralph state panel for this session
const curSession = this.sessions.get(sessionId);
if (curSession && (curSession.ralphLoop || curSession.ralphTodos)) {
this.updateRalphState(sessionId, {
loop: curSession.ralphLoop,
todos: curSession.ralphTodos
});
}
this.renderRalphStatePanel();
// Update CLI info bar (mobile - shows Claude version/model)
this.updateCliInfoDisplay();
// Update project insights panel for this session
this.renderProjectInsightsPanel();
// Update subagent window visibility for active session
this.updateSubagentWindowVisibility();
// Load file browser if enabled
const settings = this.loadAppSettingsFromStorage();
if (settings.showFileBrowser) {
const fileBrowserPanel = this.$('fileBrowserPanel');
if (fileBrowserPanel) {
fileBrowserPanel.classList.add('visible');
this.loadFileBrowser(sessionId);
// Attach drag listeners if not already attached
if (!this.fileBrowserDragListeners) {
const header = fileBrowserPanel.querySelector('.file-browser-header');
if (header) {
const onFirstDrag = () => {
if (!fileBrowserPanel.style.left) {
const rect = fileBrowserPanel.getBoundingClientRect();
fileBrowserPanel.style.left = `${rect.left}px`;
fileBrowserPanel.style.top = `${rect.top}px`;
fileBrowserPanel.style.right = 'auto';
}
};
header.addEventListener('mousedown', onFirstDrag);
header.addEventListener('touchstart', onFirstDrag, { passive: true });
this.fileBrowserDragListeners = this.makeWindowDraggable(fileBrowserPanel, header);
this.fileBrowserDragListeners._onFirstDrag = onFirstDrag;
}
}
}
}
}
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
_cleanupSessionData(sessionId) {
this.closeTabRailActionMenu?.();
// A dead session has no buffer to reconcile; leaving the marker set would
// make a later socket for a REUSED id reconcile against nothing.
this._markTerminalBufferReconciled(sessionId);
// 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();
}
this.sessions.delete(sessionId);
// Remove from tab order
const orderIndex = this.sessionOrder.indexOf(sessionId);
if (orderIndex !== -1) {
this.sessionOrder.splice(orderIndex, 1);
this.saveSessionOrder();
}
this.terminalBuffers.delete(sessionId);
this.terminalBufferCache.delete(sessionId);
this._clearHistoryTruncation(sessionId);
this._xtermSnapshots?.delete(sessionId);
try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
this._flushedOffsets?.delete(sessionId);
this._flushedTexts?.delete(sessionId);
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.discardComposerDraft?.(sessionId);
// 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();
this.ralphStates.delete(sessionId);
this.ralphClosedSessions.delete(sessionId);
this.projectInsights.delete(sessionId);
this.pendingHooks.delete(sessionId);
this.tabAlerts.delete(sessionId);
this.attachmentHistoryCounts.delete(sessionId);
if (this.attachmentHistoryDrawerOpen && this.activeSessionId === sessionId) {
this.closeAttachmentHistory?.();
}
this.terminalLoadStates.delete(sessionId);
this.clearCountdownTimers(sessionId);
this.closeSessionLogViewerWindows(sessionId);
this.closeSessionImagePopups(sessionId);
this.closeSessionAttachmentCards(sessionId);
this.closeSessionSubagentWindows(sessionId, true);
// Clean up idle timer
const idleTimer = this.idleTimers.get(sessionId);
if (idleTimer) {
clearTimeout(idleTimer);
this.idleTimers.delete(sessionId);
}
// Clean up respawn state
delete this.respawnStatus[sessionId];
delete this.respawnTimers[sessionId];
delete this.respawnCountdownTimers[sessionId];
delete this.respawnActionLogs[sessionId];
}
async closeSession(sessionId, killMux = true) {
// ⚠️ Captured BEFORE the await, and the delete is announced to
// _onSessionDeleted through _closingSessions. The `session_deleted` SSE
// broadcast for THIS delete routinely lands while the request is still in
// flight, and that handler nulls activeSessionId and shows the welcome
// screen. Re-reading the field after the await therefore made the fallback
// below a coin flip: closing the tab you were on either moved you to the
// next session or dumped you on the home screen, depending on which path
// won the race (both outcomes measured on one build, 2026-08-17).
const wasActive = this.activeSessionId === sessionId;
// Tile grid open: the fallback is the NEIGHBOURING TILE, never the first
// sessionOrder entry (often not tiled, which would collapse the grid).
// Captured here for the same reason as wasActive: the SSE delete can remove
// the tile while the request is still in flight.
const grid = this._tileGrid;
const tileNeighborId = grid?.has(sessionId) ? window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId) : null;
this._closingSessions.add(sessionId);
try {
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
this._cleanupSessionData(sessionId);
// The last tile leaving closes the grid (no reselect): the pick below runs.
if (grid?.has(sessionId)) this.removeTile(sessionId, { refocus: false });
if (wasActive && grid?.open) {
// `auto`: the app chose this tile because the previous one went away.
const target = grid.has(tileNeighborId) ? tileNeighborId : grid.ids[0];
this._selectTiledSession(target, { auto: true });
} else if (wasActive) {
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
// Next tab in the user's own order, skipping ids the cleanup has not
// caught up with yet: sessionOrder can transiently hold a dead id
// (delete racing the order sync), which is the same reason Alt+N
// indexes a live-filtered list rather than sessionOrder directly.
const nextSessionId = this.sessionOrder.find((id) => id !== sessionId && this.sessions.has(id));
if (nextSessionId) {
// `auto`: this tab was chosen by the app because the previous one
// went away, so it must not spend that session's idle alert.
this.selectSession(nextSessionId, { auto: true });
} else {
this.terminal.clear();
this.showWelcome();
this.renderRalphStatePanel(); // Clear ralph panel when no sessions
}
}
this.renderSessionTabs();
if (killMux) {
this.showToast('Session closed and tmux killed', 'success');
} else {
this.showToast('Tab hidden, tmux still running', 'info');
}
} catch (err) {
this.showToast('Failed to close session', 'error');
} finally {
this._closingSessions.delete(sessionId);
}
}
// Request confirmation before closing a session
requestCloseSession(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return;
this.pendingCloseSessionId = sessionId;
// Show session name in confirmation dialog
const name = this.getSessionName(session);
const sessionNameEl = document.getElementById('closeConfirmSessionName');
sessionNameEl.textContent = name;
// Update kill button text based on session mode
const killTitle = document.getElementById('closeConfirmKillTitle');
if (killTitle) {
killTitle.textContent = session.mode === 'opencode'
? 'Kill Tmux & OpenCode'
: session.mode === 'codex'
? 'Kill Tmux & Codex'
: session.mode === 'gemini'
? 'Kill Tmux & Gemini'
: session.mode === 'antigravity'
? 'Kill Tmux & Antigravity'
: session.mode === 'pi'
? 'Kill Tmux & Pi'
: session.mode === 'grok'
? 'Kill Tmux & Grok'
: session.mode === 'deepseek'
? 'Kill Tmux & DeepSeek'
: session.mode === 'omp'
? 'Kill Tmux & OMP'
: 'Kill Tmux & Claude Code';
}
document.getElementById('closeConfirmModal').classList.add('active');
}
cancelCloseSession() {
this.pendingCloseSessionId = null;
document.getElementById('closeConfirmModal').classList.remove('active');
}
async confirmCloseSession(killMux = true) {
const sessionId = this.pendingCloseSessionId;
this.cancelCloseSession();
if (sessionId) {
await this.closeSession(sessionId, killMux);
}
}
nextSession() {
// With the tile grid open, Ctrl+Tab and Alt+[ / Alt+] cycle through the tiles.
if (this._cycleTileFocus?.(1)) return;
if (this.sessionOrder.length <= 1) return;
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
const nextIndex = (currentIndex + 1) % this.sessionOrder.length;
this.selectSession(this.sessionOrder[nextIndex]);
}
prevSession() {
if (this._cycleTileFocus?.(-1)) return;
if (this.sessionOrder.length <= 1) return;
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
const prevIndex = (currentIndex - 1 + this.sessionOrder.length) % this.sessionOrder.length;
this.selectSession(this.sessionOrder[prevIndex]);
}
// ═══════════════════════════════════════════════════════════════
// Navigation
// ═══════════════════════════════════════════════════════════════
goHome() {
// Going Home is choosing something else, so a `#session=<id>` link still
// waiting for its session must not take the screen later.
this._retireUrlSession();
// Home is a choice to leave the grid too; it is remembered for Tiles.
this.closeTileGrid?.({ keepStored: true, reselect: false });
// Deselect active session and show welcome screen
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
this.terminal.clear();
this.showWelcome();
this.renderSessionTabs();
this.renderRalphStatePanel();
}
// ═══════════════════════════════════════════════════════════════
// Ralph Loop Wizard (methods in ralph-wizard.js)
// ═══════════════════════════════════════════════════════════════
// Wizard state (initialized here, methods loaded from ralph-wizard.js)
ralphWizardStep = 1;
ralphWizardConfig = {
taskDescription: '',
completionPhrase: 'COMPLETE',
maxIterations: 10,
caseName: 'testcase',
enableRespawn: false,
generatedPlan: null,
planGenerated: false,
skipPlanGeneration: false,
planDetailLevel: 'detailed',
existingPlan: null,
useExistingPlan: false,
};
planLoadingTimer = null;
planLoadingStartTime = null;
// ═══════════════════════════════════════════════════════════════
// Kill Sessions
// ═══════════════════════════════════════════════════════════════
async killActiveSession() {
if (!this.activeSessionId) {
this.showToast('No active session', 'warning');
return;
}
await this.closeSession(this.activeSessionId);
}
async killAllSessions() {
if (this.sessions.size === 0) return;
if (!confirm(`Kill all ${this.sessions.size} session(s)?`)) return;
try {
await this._apiDelete('/api/sessions');
// Every tiled session is gone: nothing left to remember or reselect.
this.closeTileGrid?.({ keepStored: false, reselect: false });
this.sessions.clear();
this.terminalBuffers.clear();
this.terminalBufferCache.clear();
this.terminalLoadStates.clear();
this._xtermSnapshots?.clear();
try {
for (const k of Object.keys(localStorage)) {
if (k.startsWith('codeman-xs-')) localStorage.removeItem(k);
}
} catch {}
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
this.respawnStatus = {};
this.respawnCountdownTimers = {};
this.respawnActionLogs = {};
this.stopCountdownInterval();
this.hideRespawnBanner();
this.renderSessionTabs();
this.terminal.clear();
this.showWelcome();
this.showToast('All sessions killed', 'success');
} catch (err) {
this.showToast('Failed to kill sessions', 'error');
}
}
// ═══════════════════════════════════════════════════════════════
// Timer
// ═══════════════════════════════════════════════════════════════
showTimer() {
document.getElementById('timerBanner').style.display = 'flex';
this.updateTimer();
this.timerInterval = setInterval(() => this.updateTimer(), 1000);
}
hideTimer() {
document.getElementById('timerBanner').style.display = 'none';
if (this.timerInterval) {
clearInterval(this.timerInterval);
this.timerInterval = null;
}
}
updateTimer() {
if (!this.currentRun || this.currentRun.status !== 'running') return;
const now = Date.now();
const remaining = Math.max(0, this.currentRun.endAt - now);
const total = this.currentRun.endAt - this.currentRun.startedAt;
const elapsed = now - this.currentRun.startedAt;
const percent = Math.min(100, (elapsed / total) * 100);
document.getElementById('timerValue').textContent = this.formatTime(remaining);
document.getElementById('timerProgress').style.width = `${percent}%`;
document.getElementById('timerMeta').textContent =
`${this.currentRun.completedTasks} tasks | $${this.currentRun.totalCost.toFixed(2)}`;
}
async stopCurrentRun() {
if (!this.currentRun) return;
try {
await fetch(`/api/scheduled/${this.currentRun.id}`, { method: 'DELETE' });
} catch (err) {
this.showToast('Failed to stop run', 'error');
}
}
formatTime(ms) {
const s = Math.floor(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}
// ═══════════════════════════════════════════════════════════════
// Tokens
// ═══════════════════════════════════════════════════════════════
updateCost() {
// Now updates tokens instead of cost
this.updateTokens();
}
updateTokens() {
// Debounce at 200ms — token display is non-critical and shouldn't
// compete with input handling on the main thread
this._clearTimer('_updateTokensTimeout');
this._updateTokensTimeout = setTimeout(() => {
this._updateTokensTimeout = null;
this._updateTokensImmediate();
}, 200);
}
_updateTokensImmediate() {
// Use global stats if available (includes deleted sessions)
let totalInput = 0;
let totalOutput = 0;
if (this.globalStats) {
totalInput = this.globalStats.totalInputTokens || 0;
totalOutput = this.globalStats.totalOutputTokens || 0;
} else {
// Fallback to active sessions only
this.sessions.forEach(s => {
if (s.tokens) {
totalInput += s.tokens.input || 0;
totalOutput += s.tokens.output || 0;
}
});
}
const total = totalInput + totalOutput;
this.totalTokens = total;
const display = this.formatTokens(total);
// Estimate cost from tokens (more accurate than stored cost in interactive mode)
const estimatedCost = this.estimateCost(totalInput, totalOutput);
const tokenEl = this.$('headerTokens');
if (tokenEl) {
const settings = this.loadAppSettingsFromStorage();
const showCost = settings.showCost ?? false;
tokenEl.textContent = total > 0
? (showCost ? `${display} tokens · $${estimatedCost.toFixed(2)}` : `${display} tokens`)
: '0 tokens';
tokenEl.title = this.globalStats
? `Lifetime: ${this.globalStats.totalSessionsCreated} sessions created${showCost ? '\nEstimated cost based on Claude Opus pricing' : ''}`
: `Token usage across active sessions${showCost ? '\nEstimated cost based on Claude Opus pricing' : ''}`;
}
}
// ─── 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;
// 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;
});
}
matchesShortcutEvent(e, shortcut) {
if (!shortcut || !Array.isArray(shortcut.bindings)) return false;
return shortcut.bindings.some((binding) => {
const mods = binding.modifiers || [];
// 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;
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) => {
// Key names, never translated: "Home" is also a dictionary word (the Home
// button), so without the skip zh-CN showed the key as 主页.
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd data-i18n-skip>${escapeHtml(b)}</kbd>`).join(' / ');
if (!s.bindings) return '';
// An action with no key (Close Session by default) is still listed, so the
// overlay says so instead of showing an empty key column.
if (s.bindings.length === 0) return '<span class="shortcut-overlay-unbound">not bound</span>';
return s.bindings.map((b) => {
const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
return `<kbd data-i18n-skip>${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');
}
}
// ═══════════════════════════════════════════════════════════════
// Module Init — localStorage migration and app start
// ═══════════════════════════════════════════════════════════════
// Migrate legacy localStorage keys (claudeman-* → codeman-*)
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.startsWith('claudeman-') || key.startsWith('claudeman_'))) {
const newKey = key.replace(/^claudeman[-_]/, (m) => 'codeman' + m.charAt(m.length - 1));
if (localStorage.getItem(newKey) === null) {
localStorage.setItem(newKey, localStorage.getItem(key));
}
}
}
} catch {}
// Initialize — 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;
});
window.MobileDetection = MobileDetection;