/** * @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, log(msg) { const entry = `${new Date().toISOString().slice(11,23)} ${msg}`; this._entries.push(entry); if (this._entries.length > this._maxEntries) this._entries.shift(); try { localStorage.setItem('codeman-crash-diag', this._entries.join('\n')); } catch {} } }; // Log previous crash breadcrumbs on startup try { const prev = localStorage.getItem('codeman-crash-diag'); if (prev) console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev); } catch {} _crashDiag.log('PAGE LOAD'); // Heartbeat: send breadcrumbs to server every 2s so they survive tab freezes. setInterval(() => { try { localStorage.setItem('codeman-crash-heartbeat', String(Date.now())); if (_crashDiag._entries.length > 0) { navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n') })); } } catch {} }, 2000); window.addEventListener('error', (e) => { _crashDiag.log(`ERROR: ${e.message} at ${e.filename}:${e.lineno}`); console.error('[CRASH-DIAG] Uncaught error:', e.message, '\n File:', e.filename, ':', e.lineno, ':', e.colno, '\n Stack:', e.error?.stack); }); window.addEventListener('unhandledrejection', (e) => { _crashDiag.log(`UNHANDLED: ${e.reason?.message || e.reason}`); console.error('[CRASH-DIAG] Unhandled promise rejection:', e.reason?.message || e.reason, '\n Stack:', e.reason?.stack); }); // Detect long tasks (>50ms main thread blocks) — these cause "page unresponsive" if (typeof PerformanceObserver !== 'undefined') { try { const longTaskObserver = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.duration > 200) { _crashDiag.log(`LONG_TASK: ${entry.duration.toFixed(0)}ms`); console.warn(`[CRASH-DIAG] Long task: ${entry.duration.toFixed(0)}ms (type: ${entry.entryType}, name: ${entry.name})`); } } }); longTaskObserver.observe({ type: 'longtask', buffered: true }); } catch { /* longtask not supported */ } } // Track WebGL context loss/restore events on all canvases const _origGetContext = HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = function(type, ...args) { const ctx = _origGetContext.call(this, type, ...args); if (type === 'webgl2' || type === 'webgl') { this.addEventListener('webglcontextlost', (e) => { _crashDiag.log(`WEBGL_LOST: ${this.width}x${this.height}`); console.error('[CRASH-DIAG] WebGL context LOST on canvas', this.width, 'x', this.height, '— prevented:', e.defaultPrevented); }); this.addEventListener('webglcontextrestored', () => { _crashDiag.log('WEBGL_RESTORED'); console.warn('[CRASH-DIAG] WebGL context restored'); }); } return ctx; }; // ═══════════════════════════════════════════════════════════════ // SSE Handler Map — event-to-method routing table // ═══════════════════════════════════════════════════════════════ // connectSSE() iterates this array to register all listeners in a single loop. // Omitted no-op events (registered by server but unused in UI): // respawn:stepSent, respawn:aiCheckStarted, respawn:aiCheckCompleted, // respawn:aiCheckFailed, respawn:aiCheckCooldown const _SSE_HANDLER_MAP = [ // Core [SSE_EVENTS.INIT, '_onInit'], // Session lifecycle [SSE_EVENTS.SESSION_CREATED, '_onSessionCreated'], [SSE_EVENTS.SESSION_UPDATED, '_onSessionUpdated'], [SSE_EVENTS.SESSION_DELETED, '_onSessionDeleted'], [SSE_EVENTS.SESSION_TERMINAL, '_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_CLI_INFO, '_onSessionCliInfo'], // Scheduled runs [SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'], [SSE_EVENTS.SCHEDULED_UPDATED, '_onScheduledUpdated'], [SSE_EVENTS.SCHEDULED_COMPLETED, '_onScheduledCompleted'], [SSE_EVENTS.SCHEDULED_STOPPED, '_onScheduledStopped'], // Respawn [SSE_EVENTS.RESPAWN_STARTED, '_onRespawnStarted'], [SSE_EVENTS.RESPAWN_STOPPED, '_onRespawnStopped'], [SSE_EVENTS.RESPAWN_STATE_CHANGED, '_onRespawnStateChanged'], [SSE_EVENTS.RESPAWN_CYCLE_STARTED, '_onRespawnCycleStarted'], [SSE_EVENTS.RESPAWN_BLOCKED, '_onRespawnBlocked'], [SSE_EVENTS.RESPAWN_AUTO_ACCEPT_SENT, '_onRespawnAutoAcceptSent'], [SSE_EVENTS.RESPAWN_DETECTION_UPDATE, '_onRespawnDetectionUpdate'], [SSE_EVENTS.RESPAWN_TIMER_STARTED, '_onRespawnTimerStarted'], [SSE_EVENTS.RESPAWN_TIMER_CANCELLED, '_onRespawnTimerCancelled'], [SSE_EVENTS.RESPAWN_TIMER_COMPLETED, '_onRespawnTimerCompleted'], [SSE_EVENTS.RESPAWN_ERROR, '_onRespawnError'], [SSE_EVENTS.RESPAWN_ACTION_LOG, '_onRespawnActionLog'], // Tasks [SSE_EVENTS.TASK_CREATED, '_onTaskCreated'], [SSE_EVENTS.TASK_COMPLETED, '_onTaskCompleted'], [SSE_EVENTS.TASK_FAILED, '_onTaskFailed'], [SSE_EVENTS.TASK_UPDATED, '_onTaskUpdated'], // Mux (tmux) [SSE_EVENTS.MUX_CREATED, '_onMuxCreated'], [SSE_EVENTS.MUX_KILLED, '_onMuxKilled'], [SSE_EVENTS.MUX_DIED, '_onMuxDied'], [SSE_EVENTS.MUX_STATS_UPDATED, '_onMuxStatsUpdated'], // Ralph [SSE_EVENTS.SESSION_RALPH_LOOP_UPDATE, '_onRalphLoopUpdate'], [SSE_EVENTS.SESSION_RALPH_TODO_UPDATE, '_onRalphTodoUpdate'], [SSE_EVENTS.SESSION_RALPH_COMPLETION_DETECTED, '_onRalphCompletionDetected'], [SSE_EVENTS.SESSION_RALPH_STATUS_UPDATE, '_onRalphStatusUpdate'], [SSE_EVENTS.SESSION_CIRCUIT_BREAKER_UPDATE, '_onCircuitBreakerUpdate'], [SSE_EVENTS.SESSION_EXIT_GATE_MET, '_onExitGateMet'], // Bash tools [SSE_EVENTS.SESSION_BASH_TOOL_START, '_onBashToolStart'], [SSE_EVENTS.SESSION_BASH_TOOL_END, '_onBashToolEnd'], [SSE_EVENTS.SESSION_BASH_TOOLS_UPDATE, '_onBashToolsUpdate'], // Hooks (Claude Code hook events) [SSE_EVENTS.HOOK_IDLE_PROMPT, '_onHookIdlePrompt'], [SSE_EVENTS.HOOK_PERMISSION_PROMPT, '_onHookPermissionPrompt'], [SSE_EVENTS.HOOK_ELICITATION_DIALOG, '_onHookElicitationDialog'], [SSE_EVENTS.HOOK_STOP, '_onHookStop'], [SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'], [SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'], // Subagents (Claude Code background agents) [SSE_EVENTS.SUBAGENT_DISCOVERED, '_onSubagentDiscovered'], [SSE_EVENTS.SUBAGENT_UPDATED, '_onSubagentUpdated'], [SSE_EVENTS.SUBAGENT_TOOL_CALL, '_onSubagentToolCall'], [SSE_EVENTS.SUBAGENT_PROGRESS, '_onSubagentProgress'], [SSE_EVENTS.SUBAGENT_MESSAGE, '_onSubagentMessage'], [SSE_EVENTS.SUBAGENT_TOOL_RESULT, '_onSubagentToolResult'], [SSE_EVENTS.SUBAGENT_COMPLETED, '_onSubagentCompleted'], // Images [SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'], // Tunnel [SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'], [SSE_EVENTS.TUNNEL_STOPPED, '_onTunnelStopped'], [SSE_EVENTS.TUNNEL_PROGRESS, '_onTunnelProgress'], [SSE_EVENTS.TUNNEL_ERROR, '_onTunnelError'], [SSE_EVENTS.TUNNEL_QR_ROTATED, '_onTunnelQrRotated'], [SSE_EVENTS.TUNNEL_QR_REGENERATED, '_onTunnelQrRegenerated'], [SSE_EVENTS.TUNNEL_QR_AUTH_USED, '_onTunnelQrAuthUsed'], // Plan orchestration [SSE_EVENTS.PLAN_SUBAGENT, '_onPlanSubagent'], [SSE_EVENTS.PLAN_PROGRESS, '_onPlanProgress'], [SSE_EVENTS.PLAN_STARTED, '_onPlanStarted'], [SSE_EVENTS.PLAN_CANCELLED, '_onPlanCancelled'], [SSE_EVENTS.PLAN_COMPLETED, '_onPlanCompleted'], // 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 Name Prefix Parser // ═══════════════════════════════════════════════════════════════ // Parses w- or s- 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; } // ═══════════════════════════════════════════════════════════════ // CodemanApp Class — constructor and global state // ═══════════════════════════════════════════════════════════════ class CodemanApp { constructor() { this.sessions = new Map(); this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results this.sessionOrder = []; // Track tab order for drag-and-drop reordering this.draggedTabId = null; // Currently dragged tab session ID this.cases = []; this.currentRun = null; this.totalTokens = 0; this.globalStats = null; // Global token/cost stats across all sessions this.eventSource = null; this.terminal = null; this.fitAddon = null; this.activeSessionId = null; this._initGeneration = 0; // dedup concurrent handleInit calls this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive this._selectGeneration = 0; // cancel stale selectSession loads this.respawnStatus = {}; this.respawnTimers = {}; // Track timed respawn timers this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } } this.respawnActionLogs = {}; // { sessionId: [action, action, ...] } (max 20) this.timerCountdownInterval = null; // Interval for updating countdown display this.terminalBuffers = new Map(); // Store terminal content per session this.editingSessionId = null; // Session being edited in options modal this.pendingCloseSessionId = null; // Session pending close confirmation this.muxSessions = []; // Screen sessions for process monitor // Ralph loop/todo state per session this.ralphStates = new Map(); // Map // Subagent (Claude Code background agent) tracking this.subagents = new Map(); // Map this.subagentActivity = new Map(); // Map - recent tool calls/progress this.subagentToolResults = new Map(); // Map> - tool results by toolUseId this.activeSubagentId = null; // Currently selected subagent for detail view this.subagentPanelVisible = false; this.subagentWindows = new Map(); // Map this.subagentWindowZIndex = ZINDEX_SUBAGENT_BASE; this.minimizedSubagents = new Map(); // Map> - 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 this.teamTasks = new Map(); // Map this.teammateMap = new Map(); // Map for quick lookup // Teammate tmux pane terminals (Agent Teams feature) this.teammatePanesByName = new Map(); // Map this.teammateTerminals = new Map(); // Map this.terminalBufferCache = new Map(); // Map — 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 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 this.logViewerWindows = new Map(); // Map 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 this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE; // File browser state (methods in panels-ui.js) this.fileBrowserData = null; this.fileBrowserExpandedDirs = new Set(); this.fileBrowserFilter = ''; this.fileBrowserAllExpanded = false; this.fileBrowserDragListeners = null; this.filePreviewContent = ''; // Toast container cache (methods in panels-ui.js) this._toastContainer = null; // Tunnel indicator state this._tunnelUrl = null; // Tab alert states: Map this.tabAlerts = new Map(); // Pending hooks per session: Map> // Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt) this.pendingHooks = 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 // Terminal write batching with DEC 2026 sync support this.pendingWrites = []; this.writeFrameScheduled = false; this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll this.syncWaitTimeout = null; // Timeout for incomplete sync blocks this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes this._loadBufferQueue = null; // queued SSE events during buffer load // Flicker filter state (buffers output after screen clears) this.flickerFilterBuffer = ''; this.flickerFilterActive = false; this.flickerFilterTimeout = null; // Render 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; // Offline input queue this._inputQueue = new Map(); // Map this._inputQueueMaxBytes = 64 * 1024; // 64KB cap per session this._connectionStatus = 'connected'; // Sequential input send chain — ensures keystroke ordering across async fetches this._inputSendChain = Promise.resolve(); // Local echo overlay — DOM overlay positioned at the visible ❯ prompt // (not at buffer.cursorY, which reflects Ink's internal cursor position) this._localEchoOverlay = null; // created after terminal.open() this._localEchoEnabled = false; // true when setting on + session active this._restoringFlushedState = false; // true during selectSession buffer load — protects flushed Maps // Accessibility: Focus trap for modals this.activeFocusTrap = null; // Notification system this.notificationManager = new NotificationManager(this); this.idleTimers = new Map(); // Map 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(); 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); } updateTabAlertFromHooks(sessionId) { const hooks = this.pendingHooks.get(sessionId); if (!hooks || hooks.size === 0) { this.tabAlerts.delete(sessionId); } else if (hooks.has('permission_prompt') || hooks.has('elicitation_dialog')) { this.tabAlerts.set(sessionId, 'action'); } else if (hooks.has('idle_prompt')) { this.tabAlerts.set(sessionId, 'idle'); } this.renderSessionTabs(); } // ═══════════════════════════════════════════════════════════════ // Init — app bootstrap and mobile setup // ═══════════════════════════════════════════════════════════════ init() { // Initialize mobile detection first (adds device classes to body) MobileDetection.init(); // Initialize mobile handlers KeyboardHandler.init(); SwipeHandler.init(); VoiceInput.init(); KeyboardAccessoryBar.init(); // Apply keyboard bar mode from settings const _kbSettings = this.loadAppSettingsFromStorage(); if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended'); this.applyHeaderVisibilitySettings(); this.applyTabWrapSettings(); this.applyMonitorVisibility(); // Remove mobile-init class now that JS has applied visibility settings. // The inline