/** * @fileoverview App settings modal, visibility settings (header/panel/device-specific defaults), * web push notifications, session lifecycle log (JSONL viewer), tunnel/QR management, * persistent parent associations, and help modal. * Includes 13 SSE handlers for hooks and tunnel events. * * @mixin Extends CodemanApp.prototype via Object.assign * @dependency app.js (CodemanApp class, this.notificationManager, this._tunnelUrl) * @dependency constants.js (escapeHtml) * @dependency keyboard-accessory.js (FocusTrap) * @loadorder 10 of 15 — loaded after ralph-panel.js, before panels-ui.js */ Object.assign(CodemanApp.prototype, { // Hooks (Claude Code hook events) _onHookIdlePrompt(data) { // A prompt the server opened ALREADY acknowledged raises no alert here. Today that // means the session is watching work it started itself (`acknowledgedReason` reads // "watching 1 monitor"), so the pane is quiet because the agent is waiting for its // own monitor, not for you. The item still exists and still shows in the drawer; // only the tab alert and the desktop notification are declined. A page that reloads // instead of receiving this event reaches the same conclusion from `acknowledgedAt` // in seedApprovals (approvals-ui.js). if (data.acknowledgedReason) return; // Always track pending hook - alert will show when switching away from session if (data.sessionId) { this.setPendingHook(data.sessionId, 'idle_prompt'); } this._notifySession(data.sessionId, 'warning', 'hook-idle', 'Waiting for Input', data.message || 'Claude is idle and waiting for a prompt'); }, _onHookPermissionPrompt(data) { // Always track pending hook - action alerts need user interaction to clear if (data.sessionId) { this.setPendingHook(data.sessionId, 'permission_prompt'); } const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : ''; this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || 'Claude needs tool approval to continue'); }, _onHookElicitationDialog(data) { // Always track pending hook - action alerts need user interaction to clear if (data.sessionId) { this.setPendingHook(data.sessionId, 'elicitation_dialog'); } this._notifySession(data.sessionId, 'critical', 'hook-elicitation', 'Question Asked', data.question || 'Claude is asking a question and waiting for your answer'); }, _onHookElicitationComplete(data) { // Question answered in the terminal: clear the action alert without // waiting for `stop` (the turn may keep running for a long time). // ⚠️ BOTH action kinds, matching the server's APPROVAL_RESOLVING_EVENTS, // which resolves a session's pending item whatever its kind. An // AskUserQuestion dialog arrives as `permission_prompt` (only MCP // elicitation is `elicitation_dialog`), so clearing just the elicitation // entry left the red alert armed on exactly the dialog these events are // most often about. Normally the server's `approval:resolved` broadcast // clears it too; this is the path that still works when the store holds no // item for the session (restart, superseded). if (data.sessionId) { this.clearPendingHooks(data.sessionId, 'elicitation_dialog'); this.clearPendingHooks(data.sessionId, 'permission_prompt'); } }, _onHookElicitationResponse(data) { this._onHookElicitationComplete(data); }, _onHookStop(data) { // Clear all pending hooks when Claude finishes responding if (data.sessionId) { this.clearPendingHooks(data.sessionId); } this._notifySession(data.sessionId, 'info', 'hook-stop', 'Response Complete', data.reason || 'Claude has finished responding'); }, _onHookAgentWorking(data) { // The agent started a turn, so whatever it was blocked on is gone. Reported // by the DeepSeek status bridge; a harness turn cannot run while one of its // own modal approvals is on screen, so this means the dialog was answered in // the terminal. Same clearing as _onHookElicitationComplete, and notably NOT // a notification: a turn STARTING is not news. if (data.sessionId) { this.clearPendingHooks(data.sessionId, 'elicitation_dialog'); this.clearPendingHooks(data.sessionId, 'permission_prompt'); this.clearPendingHooks(data.sessionId, 'idle_prompt'); } }, _onHookTeammateIdle(data) { const session = this.sessions.get(data.sessionId); this._notifySession(data.sessionId, 'warning', 'hook-teammate-idle', 'Teammate Idle', `A teammate is idle in ${session?.name || data.sessionId}`); }, _onHookTaskCompleted(data) { const session = this.sessions.get(data.sessionId); this._notifySession(data.sessionId, 'info', 'hook-task-completed', 'Task Completed', `A team task completed in ${session?.name || data.sessionId}`); }, // Tunnel _onTunnelStarted(data) { console.log('[Tunnel] Started:', data.url); this._tunnelUrl = data.url; this._dismissTunnelConnecting(); this._updateTunnelUrlDisplay(data.url); this._updateTunnelIndicator(true); const welcomeVisible = document.getElementById('welcomeOverlay')?.classList.contains('visible'); if (welcomeVisible) { // On welcome screen: QR appears inline, expanded first this._updateWelcomeTunnelBtn(true, data.url, true); this.showToast(`Tunnel active`, 'success'); } else { // Not on welcome screen: popup QR overlay this._updateWelcomeTunnelBtn(true, data.url); this.showToast(`Tunnel active: ${data.url}`, 'success'); this.showTunnelQR(); } }, _onTunnelStopped() { console.log('[Tunnel] Stopped'); this._tunnelUrl = null; this._dismissTunnelConnecting(); this._updateTunnelUrlDisplay(null); this._updateWelcomeTunnelBtn(false); this._updateTunnelIndicator(false); this.closeTunnelPanel(); this.closeTunnelQR(); }, _onTunnelProgress(data) { console.log('[Tunnel] Progress:', data.message); const toast = document.getElementById('tunnelConnectingToast'); if (toast) { toast.innerHTML = ` ${data.message}`; } // Also update button text if on welcome screen const btn = document.getElementById('welcomeTunnelBtn'); if (btn?.classList.contains('connecting')) { btn.innerHTML = ` ${data.message}`; } }, _onTunnelError(data) { console.warn('[Tunnel] Error:', data.message); this._dismissTunnelConnecting(); this.showToast(`Tunnel error: ${data.message}`, 'error'); const btn = document.getElementById('welcomeTunnelBtn'); if (btn) { btn.disabled = false; btn.classList.remove('connecting'); } }, _onTunnelQrRotated(data) { if (data.svg) { const container = document.getElementById('tunnelQrContainer'); if (container) container.innerHTML = data.svg; const welcomeInner = document.getElementById('welcomeQrInner'); if (welcomeInner) welcomeInner.innerHTML = data.svg; } else { this._refreshTunnelQrFromApi(); } this._resetQrCountdown(); }, _onTunnelQrRegenerated(data) { if (data.svg) { const container = document.getElementById('tunnelQrContainer'); if (container) container.innerHTML = data.svg; const welcomeInner = document.getElementById('welcomeQrInner'); if (welcomeInner) welcomeInner.innerHTML = data.svg; } else { this._refreshTunnelQrFromApi(); } this._resetQrCountdown(); }, _onTunnelQrAuthUsed(data) { const ua = data.ua || 'Unknown device'; const family = ua.match(/Chrome|Firefox|Safari|Edge|Mobile/)?.[0] || 'Browser'; this.showToast(`Device authenticated via QR (${family}, ${data.ip}). Not you?`, 'warning', { duration: 10000, action: { label: 'Revoke All', onClick: () => { fetch('/api/auth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }) .then(() => this.showToast('All sessions revoked', 'success')) .catch(() => this.showToast('Failed to revoke sessions', 'error')); }}, }); }, // ═══════════════════════════════════════════════════════════════ // Web Push // ═══════════════════════════════════════════════════════════════ registerServiceWorker() { if (!('serviceWorker' in navigator)) return; // Behind a sub-path mount the worker is served at /sw.js and controls // / (Service-Worker-Allowed is '/', so this narrower scope is permitted). const _swBase = window.CodemanBase?.base || ''; navigator.serviceWorker.register(_swBase + '/sw.js', { scope: _swBase + '/' }).then((reg) => { this._swRegistration = reg; // Listen for messages from service worker (notification clicks) navigator.serviceWorker.addEventListener('message', (event) => { if (event.data?.type === 'notification-click') { const { sessionId, action, approvalId } = event.data; if (action) { // Approve/Deny action buttons on a push: answer via the // Approvals Inbox instead of just focusing the session. this.handleNotificationAction?.(action, approvalId, sessionId); } else if (sessionId && this.sessions.has(sessionId)) { this.selectSession(sessionId); } window.focus(); } }); // Check if already subscribed reg.pushManager.getSubscription().then((sub) => { if (sub) { this._pushSubscription = sub; this._updatePushUI(true); } }); }).catch(() => { // Service worker registration failed (likely not HTTPS) }); }, async subscribeToPush() { if (!this._swRegistration) { this.showToast('Service worker not available. HTTPS or localhost required.', 'error'); return; } try { // Get VAPID public key from server const keyData = await this._apiJson('/api/push/vapid-key'); if (!keyData) throw new Error('Failed to get VAPID key'); const applicationServerKey = urlBase64ToUint8Array(keyData.publicKey); const subscription = await this._swRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey, }); // Send subscription to server const subJson = subscription.toJSON(); const data = await this._apiJson('/api/push/subscribe', { method: 'POST', body: { endpoint: subJson.endpoint, keys: subJson.keys, userAgent: navigator.userAgent, pushPreferences: this._buildPushPreferences(), }, }); if (!data) throw new Error('Failed to register subscription'); this._pushSubscription = subscription; this._pushSubscriptionId = data.id; localStorage.setItem('codeman-push-subscription-id', data.id); this._updatePushUI(true); this.showToast('Push notifications enabled', 'success'); } catch (err) { this.showToast('Push subscription failed: ' + (err.message || err), 'error'); } }, async unsubscribeFromPush() { try { if (this._pushSubscription) { await this._pushSubscription.unsubscribe(); } const subId = this._pushSubscriptionId || localStorage.getItem('codeman-push-subscription-id'); if (subId) { await fetch(`/api/push/subscribe/${subId}`, { method: 'DELETE' }).catch(() => {}); } this._pushSubscription = null; this._pushSubscriptionId = null; localStorage.removeItem('codeman-push-subscription-id'); this._updatePushUI(false); this.showToast('Push notifications disabled', 'success'); } catch (err) { this.showToast('Failed to unsubscribe: ' + (err.message || err), 'error'); } }, async togglePushSubscription() { if (this._pushSubscription) { await this.unsubscribeFromPush(); } else { await this.subscribeToPush(); } }, /** Sync push preferences to server */ async _syncPushPreferences() { const subId = this._pushSubscriptionId || localStorage.getItem('codeman-push-subscription-id'); if (!subId) return; try { await fetch(`/api/push/subscribe/${subId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pushPreferences: this._buildPushPreferences() }), }); } catch { // Silently fail — prefs saved locally, will sync on next subscribe } }, /** Build push preferences object from current event type checkboxes */ _buildPushPreferences() { const prefs = {}; const eventMap = { 'hook:permission_prompt': 'eventPermissionPush', 'hook:elicitation_dialog': 'eventQuestionPush', 'hook:idle_prompt': 'eventIdlePush', 'hook:stop': 'eventStopPush', 'respawn:blocked': 'eventRespawnPush', 'session:ralphCompletionDetected': 'eventRalphPush', }; for (const [event, checkboxId] of Object.entries(eventMap)) { const el = document.getElementById(checkboxId); prefs[event] = el ? el.checked : true; } // session:error always receives push (no per-event toggle, always critical) prefs['session:error'] = true; return prefs; }, _updatePushUI(subscribed) { const btn = document.getElementById('pushSubscribeBtn'); const status = document.getElementById('pushSubscriptionStatus'); if (btn) btn.textContent = subscribed ? 'Unsubscribe' : 'Subscribe'; if (status) { status.textContent = subscribed ? 'active' : 'off'; status.classList.remove('granted', 'denied'); if (subscribed) status.classList.add('granted'); } }, // ═══════════════════════════════════════════════════════════════ // App Settings Modal // ═══════════════════════════════════════════════════════════════ /** * Point one terminal-weight select at its stored value. * * A stored value the picker does not list (a hand-set 350, or a weight from a * build whose options differ) is ADDED to the select rather than dropped: * otherwise `select.value = '350'` silently selects nothing, the next save * reads back '' and the setting resets itself just for having been opened. * Empty means "use xterm's default for this slot". */ populateTerminalFontWeight(select, value) { if (!select) return; const stored = value === undefined || value === null ? '' : String(value).trim(); if (stored && !Array.from(select.options).some((opt) => opt.value === stored)) { const extra = document.createElement('option'); extra.value = stored; extra.textContent = `${stored} (custom)`; select.appendChild(extra); } select.value = stored; }, /** Read one terminal-weight select back. '' means default; the resolver in constants.js validates. */ readTerminalFontWeight(select) { return select?.value.trim() || ''; }, openAppSettings() { // Load current settings const settings = this.loadAppSettingsFromStorage(); document.getElementById('appSettingsDisplayName').value = settings.displayName || 'Codeman'; document.getElementById('appSettingsLanguage').value = settings.language === 'zh-CN' ? 'zh-CN' : 'en'; document.getElementById('appSettingsClaudeMdPath').value = settings.defaultClaudeMdPath || ''; document.getElementById('appSettingsDefaultDir').value = settings.defaultWorkingDir || ''; // Use device-aware defaults for display settings (mobile has different defaults) const defaults = this.getDefaultSettings(); document.getElementById('appSettingsRalphEnabled').checked = settings.ralphTrackerEnabled ?? defaults.ralphTrackerEnabled ?? false; // Header visibility settings document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false; document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true; document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false; document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false; document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true; document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false; document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue'; // Entrance animations. Deliberately NOT part of the settings payload: the // styles persist to their own localStorage keys via setAnimTheme(), which // keeps them per-device without touching the .strict() SettingsUpdateSchema. this._syncEntranceAnimSetting?.(); // WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide // the toggle there so it can't promise something that won't apply). document.getElementById('appSettingsWebglRenderer').checked = settings.webglRendererEnabled ?? defaults.webglRendererEnabled ?? true; const webglItem = document.getElementById('appSettingsWebglRendererItem'); if (webglItem) webglItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none'; document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false; document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false; document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false; document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false; // Approvals Inbox: synced, default OFF (opt-in; only an explicit true enables). document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true; // Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both // the Run-menu picker's generated entries and this settings panel's visibility; // the endpoint list itself is server state, loaded on demand below. document.getElementById('appSettingsCustomModelEndpoints').checked = settings.customModelEndpointsEnabled === true; // Assigning .checked above does not fire onchange, so the body's visibility // (and its lazy load) needs an explicit sync on every open, not just a save. this.applyCustomModelEndpointsVisibility(); // CLI management (docs/cli-enable-disable-plan.md): synced, default OFF. document.getElementById('appSettingsCliManagement').checked = settings.cliManagementEnabled === true; // Same reasoning as applyCustomModelEndpointsVisibility above: assigning // .checked fires no onchange, so the list's visibility (and lazy load) // needs an explicit sync on every open, not just a save. this.applyCliManagementVisibility(); // Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens). document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true; document.getElementById('appSettingsUltracodeFloatingWindows').checked = settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false; document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false; document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false; document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings); document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false; // Phone overview home screen: only meaningful under 600px, so the row is // hidden elsewhere rather than offering a toggle that changes nothing. // Spawn lineage lines: desktop-only (the overlay sits UNDER the fixed mobile // header), so the row is hidden elsewhere rather than offering a toggle that // changes nothing. Default ON — only an explicit false turns it off. document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true; // Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables). document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true; const lineageItem = document.getElementById('appSettingsLineageLinesItem'); if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none'; document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false; const mobileOverviewItem = document.getElementById('appSettingsMobileOverviewItem'); if (mobileOverviewItem) mobileOverviewItem.style.display = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none'; // Session Manager, Away Digest and Cron buttons all default OFF (opt-in under // Display → Header Displays; the Cron button also ships with btn-cron--hidden // in the template, so an unchecked box and a hidden button stay consistent). document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false; document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false; document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false; // Gesture control lives in the Input section (alongside Local Echo / CJK Input) // but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets // window.__codemanGestureAvailable). Hide just this item otherwise so the toggle // can't promise something that won't work. const gestureItem = document.getElementById('appSettingsGestureControlItem'); if (gestureItem) gestureItem.style.display = window.__codemanGestureAvailable ? '' : 'none'; document.getElementById('appSettingsGestureControl').checked = settings.gestureControlEnabled ?? defaults.gestureControlEnabled ?? false; document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true; document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true; document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false; document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false; this.loadTunnelStatus(); document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); // Auto Copy (copy-on-select): per-device, default OFF everywhere. It quietly // overwrites the system clipboard on a gesture the user may have meant only as // a way to read, so it is opt-in rather than a default anyone has to discover. document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true; // Default ON, so an absent key reads as enabled rather than as off. document.getElementById('appSettingsCopyStripMargin').checked = settings.copyStripMargin !== false; document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || ''; this.populateTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight'), settings.terminalFontWeight); this.populateTerminalFontWeight( document.getElementById('appSettingsTerminalFontWeightBold'), settings.terminalFontWeightBold ); document.getElementById('appSettingsTerminalWheelLocal').checked = settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false; document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false; document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false; document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false; document.getElementById('appSettingsTabOrientation').value = settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal'; const tabRailWidth = window.CodemanTabRail?.resolveWidth({ // Same default resolution as applyTabRailWidth(): a rail that has never // been sized shows the width it is actually rendering at, which for // detailed rows is the Wide preset rather than 256. The rich-aware // default must come BEFORE the per-device defaults blob: the handheld // blob carries tabRailWidth: 256, which applyTabRailWidth() never reads, // so consulting it first showed a tablet's unsized rich rail as 256 while // it rendered at 320 — and a routine Save then PERSISTED the 256. width: settings.tabRailWidth ?? this._defaultTabRailWidth?.() ?? defaults.tabRailWidth ?? 256, }) ?? 256; this.syncTabRailWidthSetting?.(tabRailWidth); document.getElementById('appSettingsTabRailDetail').value = settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich'; document.getElementById('appSettingsTabRailSort').value = settings.tabRailSort ?? defaults.tabRailSort ?? 'activity'; document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false; document.getElementById('appSettingsSessionListLayout').value = settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header'; const sessionSidebarFontSize = this.resolveSessionSidebarFontSize( settings.sessionSidebarFontSize ?? defaults.sessionSidebarFontSize ); document.getElementById('appSettingsSessionSidebarFontSize').value = String(sessionSidebarFontSize); document.getElementById('appSettingsSessionSidebarFontSizeValue').textContent = `${sessionSidebarFontSize} px`; // Claude CLI settings const claudeModeSelect = document.getElementById('appSettingsClaudeMode'); const allowedToolsRow = document.getElementById('allowedToolsRow'); claudeModeSelect.value = settings.claudeMode || 'dangerously-skip-permissions'; document.getElementById('appSettingsAllowedTools').value = settings.allowedTools || ''; allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none'; // Toggle allowed tools row visibility based on mode selection claudeModeSelect.onchange = () => { allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none'; }; // Codex CLI settings. The inputs are always populated (and always read back // by saveAppSettings), even when the tab is hidden below, so a user without // codex installed can never silently wipe the codex prefs of an instance // that does have it. document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked = settings.codexDangerouslyBypassApprovals ?? false; document.getElementById('appSettingsCodexAnimations').checked = settings.codexAnimationsEnabled ?? false; this._applyCodexSettingsVisibility(); // Claude Permissions settings document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false; document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled ?? false; // Default ON: an absent key is a user who has never seen this setting, and OFF // for them means no tab alerts in any workspace Codeman did not scaffold. document.getElementById('appSettingsWorkspaceHooks').checked = settings.workspaceHooksEnabled !== false; document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? ''; document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false; document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true; document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? ''; document.getElementById('appSettingsClaudeAdvisor').value = settings.claudeAdvisorModel ?? ''; // CPU Priority settings const niceSettings = settings.nice || {}; document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false; document.getElementById('appSettingsNiceValue').value = niceSettings.niceValue ?? 10; // Model configuration (loaded from server) this.loadModelConfigForSettings(); // Custom Model Endpoint Profiles' own load is gated on the toggle above (see // applyCustomModelEndpointsVisibility) — unlike model config, this GET is // pointless work with the feature off, so it is not fired unconditionally. // Notification settings const notifPrefs = this.notificationManager?.preferences || {}; document.getElementById('appSettingsNotifEnabled').checked = notifPrefs.enabled ?? true; document.getElementById('appSettingsNotifBrowser').checked = notifPrefs.browserNotifications ?? false; document.getElementById('appSettingsNotifAudio').checked = notifPrefs.audioAlerts ?? false; document.getElementById('appSettingsNotifStuckMins').value = Math.round((notifPrefs.stuckThresholdMs || 600000) / 60000); document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical; document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning; document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo; // Push notification settings document.getElementById('appSettingsPushEnabled').checked = !!this._pushSubscription; this._updatePushUI(!!this._pushSubscription); // Per-event-type preferences const eventTypes = notifPrefs.eventTypes || {}; // Permission prompts const permPref = eventTypes.permission_prompt || {}; document.getElementById('eventPermissionEnabled').checked = permPref.enabled ?? true; document.getElementById('eventPermissionBrowser').checked = permPref.browser ?? true; document.getElementById('eventPermissionPush').checked = permPref.push ?? false; document.getElementById('eventPermissionAudio').checked = permPref.audio ?? true; // Questions (elicitation_dialog) const questionPref = eventTypes.elicitation_dialog || {}; document.getElementById('eventQuestionEnabled').checked = questionPref.enabled ?? true; document.getElementById('eventQuestionBrowser').checked = questionPref.browser ?? true; document.getElementById('eventQuestionPush').checked = questionPref.push ?? false; document.getElementById('eventQuestionAudio').checked = questionPref.audio ?? true; // Session idle (idle_prompt) const idlePref = eventTypes.idle_prompt || {}; document.getElementById('eventIdleEnabled').checked = idlePref.enabled ?? true; document.getElementById('eventIdleBrowser').checked = idlePref.browser ?? true; document.getElementById('eventIdlePush').checked = idlePref.push ?? false; document.getElementById('eventIdleAudio').checked = idlePref.audio ?? false; // Response complete (stop) const stopPref = eventTypes.stop || {}; document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? false; document.getElementById('eventStopBrowser').checked = stopPref.browser ?? false; document.getElementById('eventStopPush').checked = stopPref.push ?? false; document.getElementById('eventStopAudio').checked = stopPref.audio ?? false; // Respawn cycles const respawnPref = eventTypes.respawn_cycle || {}; document.getElementById('eventRespawnEnabled').checked = respawnPref.enabled ?? true; document.getElementById('eventRespawnBrowser').checked = respawnPref.browser ?? false; document.getElementById('eventRespawnPush').checked = respawnPref.push ?? false; document.getElementById('eventRespawnAudio').checked = respawnPref.audio ?? false; // Task complete (ralph_complete) const ralphPref = eventTypes.ralph_complete || {}; document.getElementById('eventRalphEnabled').checked = ralphPref.enabled ?? true; document.getElementById('eventRalphBrowser').checked = ralphPref.browser ?? true; document.getElementById('eventRalphPush').checked = ralphPref.push ?? false; document.getElementById('eventRalphAudio').checked = ralphPref.audio ?? true; // Subagent activity (subagent_spawn and subagent_complete) const subagentPref = eventTypes.subagent_spawn || {}; document.getElementById('eventSubagentEnabled').checked = subagentPref.enabled ?? false; document.getElementById('eventSubagentBrowser').checked = subagentPref.browser ?? false; document.getElementById('eventSubagentPush').checked = subagentPref.push ?? false; document.getElementById('eventSubagentAudio').checked = subagentPref.audio ?? false; // Update permission status display (compact format for new grid layout) const permStatus = document.getElementById('notifPermissionStatus'); if (permStatus && typeof Notification !== 'undefined') { const perm = Notification.permission; permStatus.textContent = perm === 'granted' ? '\u2713' : perm === 'denied' ? '\u2717' : '?'; permStatus.classList.remove('granted', 'denied'); if (perm === 'granted') permStatus.classList.add('granted'); else if (perm === 'denied') permStatus.classList.add('denied'); } // Voice settings (loaded from localStorage only) const voiceCfg = VoiceInput._getDeepgramConfig(); document.getElementById('voiceDeepgramKey').value = voiceCfg.apiKey || ''; document.getElementById('voiceLanguage').value = voiceCfg.language || 'en-US'; document.getElementById('voiceKeyterms').value = voiceCfg.keyterms || DEFAULT_VOICE_KEYTERMS; document.getElementById('voiceInsertMode').value = voiceCfg.insertMode || 'direct'; document.getElementById('voiceProvider').value = voiceCfg.provider || 'auto'; document.getElementById('appSettingsClaudeVoice').checked = settings.claudeVoiceEnabled ?? false; // Reset key visibility to hidden const keyInput = document.getElementById('voiceDeepgramKey'); keyInput.type = 'password'; document.getElementById('voiceKeyToggleBtn').textContent = 'Show'; // Update provider status. The Claude row needs a fresh server probe: the // setting is synced, so another device may have flipped it since page load. this._renderVoiceProviderStatus(); VoiceInput.refreshClaudeStatus().then(() => this._renderVoiceProviderStatus()); // Updates section — show current version, reset transient result/progress UI. this._initUpdatesSection(); // Model cards + effort segment are views over the hidden 's own options, so the * select stays the single source of truth that openAppSettings/saveAppSettings * read and write by id. The `[1m]` variants are folded away: context width is a * property of the chosen model (the "1M context window" switch), not a rival * setting that silently loses to it. */ _buildModelCards() { const select = document.getElementById('appSettingsClaudeModel'); const grid = document.getElementById('appSettingsModelCards'); if (!select || !grid || grid.dataset.built === '1' || !select.options) return; grid.innerHTML = ''; [...select.options] .filter(opt => opt.dataset.variant !== '1m') .forEach(opt => { const card = document.createElement('button'); card.type = 'button'; card.className = 'set-modelcard'; card.setAttribute('role', 'radio'); card.dataset.value = opt.value; if (opt.dataset.ctx === '1') card.dataset.ctx = '1'; const top = document.createElement('span'); top.className = 'set-mc-top'; const name = document.createElement('span'); name.className = 'set-mc-name'; name.textContent = opt.textContent; top.appendChild(name); const dot = document.createElement('span'); dot.className = 'set-mc-dot'; top.appendChild(dot); card.appendChild(top); const meta = document.createElement('span'); meta.className = 'set-mc-meta'; meta.textContent = opt.dataset.meta || ''; card.appendChild(meta); if (opt.dataset.ctx === '1') { const ctx = document.createElement('span'); ctx.className = 'set-mc-ctx'; ctx.textContent = '1M capable'; card.appendChild(ctx); } card.addEventListener('click', () => { this._settingsModelBase = opt.value; this._applyModelSelection(); }); grid.appendChild(card); }); grid.dataset.built = '1'; }, /** Derive card + context-switch state from the select's stored value. */ _syncModelCards() { const select = document.getElementById('appSettingsClaudeModel'); if (!select) return; const value = select.value || ''; this._settingsModelBase = value.endsWith('[1m]') ? value.slice(0, -4) : value; if (value.endsWith('[1m]')) { const ctx = document.getElementById('appSettingsOpusContext1m'); if (ctx) ctx.checked = true; } this._applyModelSelection(); }, /** Compose card + context switch back into the select's value. */ _applyModelSelection() { const select = document.getElementById('appSettingsClaudeModel'); const grid = document.getElementById('appSettingsModelCards'); if (!select || !grid) return; const base = this._settingsModelBase || ''; let capable = false; grid.querySelectorAll('.set-modelcard').forEach(card => { const on = card.dataset.value === base; card.classList.toggle('selected', on); card.setAttribute('aria-checked', on ? 'true' : 'false'); if (on) capable = card.dataset.ctx === '1'; }); const ctxOn = !!document.getElementById('appSettingsOpusContext1m')?.checked; select.value = base && capable && ctxOn ? `${base}[1m]` : base; // A model with no 1M variant makes the switch inert; say so instead of // leaving a toggle that looks like it does something. const row = document.getElementById('appSettingsContextRow'); const desc = document.getElementById('appSettingsContextDesc'); const inert = !!base && !capable; row?.classList.toggle('set-row-disabled', inert); if (desc) { desc.textContent = inert ? 'The selected model has no 1M variant.' : base ? 'Available for Fable 5.1, Fable 5, Opus 5.5, Opus and Opus 4.6.' : 'With no model pinned, this starts new sessions on Opus with a 1M window.'; } }, _buildEffortSegment() { this._buildSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment'); }, _syncEffortSegment() { this._syncSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment'); }, _buildAdvisorSegment() { this._buildSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment'); }, _syncAdvisorSegment() { this._syncSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment'); }, /** * Build a radio segment as a view over a hidden `; return `
${escapeHtml(c.label)} ${escapeHtml(c.shortBadge)} ${c.installed ? 'Installed' : 'Not installed'}${c.stock ? '' : ' · custom'}
${installBtn} ${customActions} ${toggle}
`; }) .join(''); }, /** * ⚠️ A successful toggle must patch `window.__codemanCliAvailable` and refresh * every surface that reads it, or the change is invisible everywhere except * this settings row until the next full page reload — `window.__codemanCliAvailable` * is injected ONCE at initial page render (server.ts) and nothing else refetches * it. Same pattern `installDeepSeekProfile()` already uses for the same reason. */ async toggleCliEnabled(id, checkbox) { const next = checkbox.checked; const res = await this._api(`/api/clis/${encodeURIComponent(id)}`, { method: 'PUT', body: { enabled: next } }); if (!res || !res.ok) { checkbox.checked = !next; // revert on failure — the row must not lie about server state let detail = ''; try { detail = (await res?.json())?.error || ''; } catch { /* no body to read */ } this.showToast(`Failed to ${next ? 'enable' : 'disable'} "${id}"${detail ? `: ${detail}` : ''}`, 'error'); return; } await this.loadCliListForSettings(); }, async installCliEntry(id) { // Installing runs a command on the server, so it never happens on a single click: // the confirm names the exact command POST /api/clis/:id/install would run (the // #343 review's "auto-install may end up behind an explicit confirm"). const entry = (this._cliList || []).find((c) => c.id === id); const label = entry?.label || id; const command = entry?.installCommand; const prompt = command ? `Install ${label}? This runs the following on the Codeman server:\n\n${command}` : `Install ${label}? This runs its official install command on the Codeman server.`; if (!confirm(prompt)) return; const btn = document.getElementById(`cliInstallBtn-${id}`); if (btn) { btn.disabled = true; btn.textContent = 'Installing…'; } try { const res = await this._api(`/api/clis/${encodeURIComponent(id)}/install`, { method: 'POST' }); if (!res || !res.ok) { let detail = ''; try { detail = (await res?.json())?.error || ''; } catch { /* no body to read */ } this.showToast(`Installing "${id}" failed${detail ? `: ${detail}` : ''}`, 'error'); return; } this.showToast(`Installed "${id}"`, 'success'); } finally { await this.loadCliListForSettings(); } }, /** * Pass no id to create a new entry; pass an existing CUSTOM id to edit one. * ⚠️ GET /api/clis deliberately excludes discovery/launch (Phase 2's own * scope), so an edit cannot be pre-filled with the entry's existing binary * or argv — those two fields start blank and must be re-entered, since the * update endpoint (PUT /api/clis/custom/:id) replaces the whole launch * spec rather than patching it. id/label/badge DO come from the list row. */ openCliCustomForm(editId) { const form = document.getElementById('cliCustomForm'); const errorEl = document.getElementById('cliCustomFormError'); if (!form) return; const existing = editId ? (this._cliList || []).find((c) => c.id === editId) : null; this._editingCliCustomId = existing ? existing.id : null; document.getElementById('cliCustomId').value = existing ? existing.id : ''; document.getElementById('cliCustomId').disabled = !!existing; // id is immutable once created document.getElementById('cliCustomLabel').value = existing ? existing.label : ''; document.getElementById('cliCustomBadge').value = existing ? existing.shortBadge : ''; document.getElementById('cliCustomBinary').value = ''; document.getElementById('cliCustomArgv').value = ''; document.getElementById('cliCustomSubmit').textContent = existing ? 'Save' : 'Create'; if (errorEl) errorEl.style.display = 'none'; form.style.display = ''; }, closeCliCustomForm() { const form = document.getElementById('cliCustomForm'); if (form) form.style.display = 'none'; this._editingCliCustomId = null; }, /** Wired to #cliCustomForm's onsubmit; `event` is the submit event. */ async submitCliCustomForm(event) { event.preventDefault(); const errorEl = document.getElementById('cliCustomFormError'); const showError = (msg) => { if (errorEl) { errorEl.textContent = msg; errorEl.style.display = ''; } }; const id = document.getElementById('cliCustomId').value.trim(); const label = document.getElementById('cliCustomLabel').value.trim(); const shortBadge = document.getElementById('cliCustomBadge').value.trim(); const binaries = document.getElementById('cliCustomBinary').value.trim().split(/\s+/).filter(Boolean); const argv = document.getElementById('cliCustomArgv').value.trim().split(/\s+/).filter(Boolean); if (!id || !label || !shortBadge || binaries.length === 0 || argv.length === 0) { showError('All fields are required.'); return; } const editing = this._editingCliCustomId; const path = editing ? `/api/clis/custom/${encodeURIComponent(editing)}` : '/api/clis'; const method = editing ? 'PUT' : 'POST'; const res = await this._api(path, { method, body: { id, label, shortBadge, binaries, argv } }); if (!res || !res.ok) { let detail = 'Request failed'; try { detail = (await res?.json())?.error || detail; } catch { /* no body to read */ } showError(detail); return; } this.closeCliCustomForm(); await this.loadCliListForSettings(); }, async deleteCliCustom(id) { const entry = (this._cliList || []).find((c) => c.id === id); if (!confirm(`Delete custom CLI "${entry?.label || id}"? This cannot be undone.`)) return; const res = await this._api(`/api/clis/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (!res || !res.ok) { let detail = ''; try { detail = (await res?.json())?.error || ''; } catch { /* no body to read */ } this.showToast(`Failed to delete "${id}"${detail ? `: ${detail}` : ''}`, 'error'); return; } await this.loadCliListForSettings(); }, // ═══════════════════════════════════════════════════════════════ // Visibility Settings & Device-Specific Defaults // ═══════════════════════════════════════════════════════════════ // Get the global Ralph tracker enabled setting isRalphTrackerEnabledByDefault() { const settings = this.loadAppSettingsFromStorage(); return settings.ralphTrackerEnabled ?? false; }, // Keep the settings namespace stable across foldable posture changes. Layout // still follows viewport width, but an unfolded phone remains the same // handheld device and must not silently switch to desktop preferences. getSettingsStorageKey() { const isHandheld = MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile'; return isHandheld ? 'codeman-app-settings-mobile' : 'codeman-app-settings'; }, // Get default settings based on device type // Note: Notification prefs are handled separately by NotificationManager getDefaultSettings() { const isHandheld = MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile'; if (isHandheld) { // Mobile defaults: minimal UI for small screens return { // Header visibility - hide everything on mobile showFontControls: false, showSystemStats: false, showTokenCount: false, showCost: false, // Panel visibility - hide panels on mobile (not enough space) showMonitor: false, showProjectInsights: false, showFileBrowser: false, showSubagents: false, showUltracodeAgents: false, ultracodeFloatingWindows: false, showMultiMonitorButton: false, showSplitButton: false, // Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it // OFF so the phone header stays minimal and the mobile-header-buttons // policy guard keeps passing. showPlanUsageLimits: false, showAttachmentsButton: false, showFileViewerButton: false, showRedrawButton: false, showSessionButton: false, showAwayDigestButton: false, showCronButton: false, // Phone home screen: the C logo opens the session overview instead of the // welcome screen. ON by default here, and the escape hatch if it ever // misbehaves on a device (the gate treats only an explicit false as off). mobileOverviewEnabled: true, // Remote auto-reconnect (COD-108) — on by default remoteAutoReconnect: true, // Input gestureControlEnabled: false, // Feature toggles - keep tracking on even on mobile subagentTrackingEnabled: true, subagentActiveTabOnly: true, // Only show subagents for active tab imageWatcherEnabled: false, ralphTrackerEnabled: false, tabTwoRows: false, tabOrientation: 'horizontal', tabRailWidth: 256, tabRailDetail: 'rich', tabRailSort: 'activity', sessionListLayout: 'header', sessionSidebarFontSize: 12, cjkInputEnabled: false, terminalWheelLocalScrollback: false, // mobile scrolls via touch, not wheel webglRendererEnabled: false, // mobile always uses the DOM renderer skin: 'daylight-blue', }; } // Desktop defaults - rely on ?? operators in apply functions // This allows desktop to have different defaults without duplication return {}; }, loadAppSettingsFromStorage() { // Return cached settings if available (avoids synchronous localStorage + JSON.parse // on every SSE event — critical for input responsiveness) if (this._cachedAppSettings) return this._cachedAppSettings; try { const key = this.getSettingsStorageKey(); const saved = localStorage.getItem(key); if (saved) { this._cachedAppSettings = JSON.parse(saved); return this._cachedAppSettings; } } catch (err) { console.error('Failed to load app settings:', err); } // Return device-specific defaults this._cachedAppSettings = this.getDefaultSettings(); return this._cachedAppSettings; }, saveAppSettingsToStorage(settings) { // Invalidate cache on save this._cachedAppSettings = settings; try { const key = this.getSettingsStorageKey(); localStorage.setItem(key, JSON.stringify(settings)); } catch (err) { console.error('Failed to save app settings:', err); } }, // Apply the chosen skin live: sets the html[data-skin] attribute, syncs BOTH // localStorage locations (the standalone 'codeman:skin' key the pre-paint head // script reads + the app-settings blob field written by saveAppSettingsToStorage), // updates window.__codemanSkin, and re-themes any live terminals. applySkin() { const settings = this.loadAppSettingsFromStorage(); const defaults = this.getDefaultSettings(); const skin = settings.skin ?? defaults.skin ?? 'daylight-blue'; document.documentElement.setAttribute('data-skin', skin); window.__codemanSkin = skin; const themeColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-dark').trim(); if (themeColor) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', themeColor); try { localStorage.setItem('codeman:skin', skin); } catch (_e) { /* private mode */ } if (typeof this.applyTerminalSkin === 'function') this.applyTerminalSkin(skin); }, // Apply the per-device language and the synced user-facing product name. // The i18n layer updates both existing static nodes and future dynamic DOM. applyLocalization() { const settings = this.loadAppSettingsFromStorage(); const result = window.CodemanI18n?.configure({ language: settings.language, displayName: settings.displayName, }); if (result && this.notificationManager) { this.notificationManager.originalTitle = document.title; } }, // Resolved per-device state of the plan-usage chip. Desktop defaults ON, // handhelds default OFF (the mobile block in getDefaultSettings() sets false, // and the mobile-header-buttons-policy guard depends on that staying false). // Single source of truth for the two call sites that must never disagree: // the App Settings checkbox and the chip's visibility. Telemetry COLLECTION // no longer has a THIRD client-side call site here at all — the server reads // this same persisted setting directly (readPlanUsageTelemetryEnabled in // hooks-config.ts), fresh, at every claude session create/respawn. planUsageChipEnabled(settings = null) { const s = settings ?? this.loadAppSettingsFromStorage(); return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true; }, // What a settings save tells the server about plan-usage COLLECTION: the new // chip value when this save FLIPS it relative to what this device resolved // before (stored value, else the per-device default), otherwise undefined, // meaning "say nothing". The server reads an absent key as ON, so a device // that never touched the chip leaves collection alone, and a handheld (chip // default OFF) cannot switch it off for every desktop by saving its font // size. Pure so test/plan-usage-collection-flip.test.ts can drive it. planUsageCollectionFlip(prevSettings, now) { const before = this.planUsageChipEnabled(prevSettings ?? {}); return now === before ? undefined : now; }, applyHeaderVisibilitySettings() { const settings = this.loadAppSettingsFromStorage(); const defaults = this.getDefaultSettings(); // Tab pop-out (open-in-new-window) button: opt-in (App Settings → Tab Bar, // default OFF, per-device). Mirrored as a class on : styles.css hides // .tab-detach without it (a tab that is already detached keeps its icon as // the re-focus affordance for the popped-out window). const showTabDetach = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false; document.documentElement.classList.toggle('tabs-show-detach', showTabDetach); const compactHeader = MobileDetection.getDeviceType() !== 'desktop'; const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false); const showSystemStats = compactHeader ? false : (settings.showSystemStats ?? defaults.showSystemStats ?? true); // Default OFF: the header stays gear + usage chips + files button unless a // stored preference explicitly re-enables the token chip (no UI toggle exists). const showTokenCount = compactHeader ? false : (settings.showTokenCount ?? defaults.showTokenCount ?? false); const fontControlsEl = document.querySelector('.header-font-controls'); const systemStatsEl = document.getElementById('headerSystemStats'); const tokenCountEl = document.getElementById('headerTokens'); if (fontControlsEl) { fontControlsEl.style.display = showFontControls ? '' : 'none'; } if (systemStatsEl) { systemStatsEl.style.display = showSystemStats ? '' : 'none'; } if (tokenCountEl) { tokenCountEl.style.display = showTokenCount ? '' : 'none'; } // Hide lifecycle log button when setting is disabled // Default OFF: the lifecycle-log document icon is opt-in; the default header // keeps only WS/CPU/MEM, the file-viewer folder, usage chips, and the gear. const showLifecycleLog = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false; const lifecycleBtn = document.querySelector('.btn-lifecycle-log'); if (lifecycleBtn) { lifecycleBtn.style.display = showLifecycleLog ? '' : 'none'; } // Hide the response viewer (eye) button when setting is disabled. // Marker class, not inline style — the base rule is display:inline-flex !important. const showResponseViewer = settings.showResponseViewer ?? defaults.showResponseViewer ?? false; const responseViewerBtn = document.querySelector('.btn-response-viewer-header'); if (responseViewerBtn) { responseViewerBtn.classList.toggle('btn-response-viewer-header--hidden', !showResponseViewer); } // Hide the attachments (history) button when disabled. Opt-in, default OFF — // marker class, base is display:inline-flex !important. const showAttachmentsButton = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false; const attachmentsBtn = document.getElementById('attachmentsHistoryBtn'); if (attachmentsBtn) { attachmentsBtn.classList.toggle('btn-attachments-history--hidden', !showAttachmentsButton); } // File Viewer header button — opt-in, default OFF. Marker class (base is // display:inline-flex !important); clicking it toggles the file browser panel. // Default ON (desktop): the folder button is part of the standard header now; // phones still hide it via mobile.css (btn-file-viewer in the phone-hidden set). const showFileViewerButton = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true; const fileViewerBtn = document.querySelector('.btn-file-viewer'); if (fileViewerBtn) { fileViewerBtn.classList.toggle('btn-file-viewer--hidden', !showFileViewerButton); } // Multi-monitor button — hidden by default (App Settings → Display → "Header // Displays"). The server renders the correct initial state on every reload; // this handles a live toggle from a settings save (no reload). Toggle the // marker class (matches the server-side reveal) rather than an inline style. const showMultiMonitorButton = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false; const multiMonitorBtn = document.querySelector('.btn-multimonitor'); if (multiMonitorBtn) { multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton); } // Split button — hidden by default, and hard-gated to desktop widths // regardless of the setting (window.CodemanSplitPane.SPLIT_PANE_MIN_WIDTH, // matching HOME_SESSIONS_MIN_WIDTH's JS-check + media-query-backstop // pattern — the CSS in styles.css is the backstop, this is the check). const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false; this._applySplitButtonVisibility?.(showSplitButton); // Ultracode/Workflow agents launcher — hidden by default; reveal when enabled. // Marker class only (base is display:inline-flex !important) so it's auto-excluded // from the mobile-header-buttons-policy guard. const showUltracodeAgents = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false; const ultracodeBtn = document.querySelector('.btn-ultracode-agents'); if (ultracodeBtn) { ultracodeBtn.classList.toggle('btn-ultracode-agents--hidden', !showUltracodeAgents); } // Read My Mind 🧠 — hidden unless the synced opt-in `readMyMindEnabled` is // ON (only an explicit true enables, mirroring the Approvals bell). Marker // class (base is display:inline-flex !important); phones hide it in // mobile.css regardless (their surface is the keyboard-accessory 🧠 key, // re-synced right below). const readMyMindBtn = document.querySelector('.btn-readmymind'); if (readMyMindBtn) { readMyMindBtn.classList.toggle('btn-readmymind--hidden', settings.readMyMindEnabled !== true); } // The accessory-bar 🧠 key shares the setting; its marker class lives on // the bar element (keyboard-accessory.js), so a live toggle from a // settings save reveals/hides it without a reload. if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.syncReadMyMind?.(); // Plan-usage chip — shown by default on desktop, OFF on handhelds (App // Settings → Display → "Plan Usage Limits"). The template always ships it // hidden because display is per-device and the server cannot know a // localStorage value, so THIS is what reveals it on every load as well as // on a live toggle. Marker class (base is display:inline-flex !important), // matching the response-viewer/multimonitor pattern. const showPlanUsageLimits = this.planUsageChipEnabled(settings); const planUsageChip = document.getElementById('planUsageChip'); if (planUsageChip) { planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits); } const showRedrawButton = settings.showRedrawButton ?? defaults.showRedrawButton ?? false; const redrawBtn = document.querySelector('.btn-redraw-terminal'); if (redrawBtn) { redrawBtn.classList.toggle('btn-redraw-terminal--hidden', !showRedrawButton); } // Session Manager button — opt-in, hidden by default (App Settings → Display). // Marker class (base is display:inline-flex !important); phones keep it hidden // via mobile.css regardless. Sessions stay reachable via the Ctrl+K palette. const showSessionButton = settings.showSessionButton ?? defaults.showSessionButton ?? false; const sessionBtn = document.querySelector('.btn-session-manager'); if (sessionBtn) { sessionBtn.classList.toggle('btn-session-manager--hidden', !showSessionButton); } // Away Digest button — opt-in, hidden by default. Same marker pattern. const showAwayDigestButton = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false; const awayDigestBtn = document.querySelector('.btn-away-digest'); if (awayDigestBtn) { awayDigestBtn.classList.toggle('btn-away-digest--hidden', !showAwayDigestButton); } // Cron button (footer toolbar) — opt-in, hidden by default. Same marker pattern. const showCronButton = settings.showCronButton ?? defaults.showCronButton ?? false; const cronBtn = document.querySelector('.btn-cron'); if (cronBtn) { cronBtn.classList.toggle('btn-cron--hidden', !showCronButton); } // Notification bell is retired (notifications live in Settings → Notifications // + the drawer); keep it hidden regardless of the notification-enabled state. const notifBtn = document.querySelector('.btn-notifications'); if (notifBtn) { notifBtn.style.display = 'none'; } // Close the drawer if notifications got disabled while it's open const notifEnabled = this.notificationManager?.preferences?.enabled ?? true; if (!notifEnabled) { const drawer = document.getElementById('notifDrawer'); if (drawer) drawer.classList.remove('open'); } }, applyTabOrientation(options = {}) { const settings = this.loadAppSettingsFromStorage(); const defaults = this.getDefaultSettings(); const sidebarOwnsTabs = this.isSessionSidebarActive?.() === true; const orientation = !this.isSoloWindow && !sidebarOwnsTabs && window.CodemanTabOverflow?.resolveTabOrientation ? window.CodemanTabOverflow.resolveTabOrientation({ deviceType: MobileDetection.getDeviceType(), setting: settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal', }) : 'horizontal'; const root = document.documentElement; const previous = root.getAttribute('data-tab-orientation') || 'horizontal'; root.setAttribute('data-tab-orientation', orientation); // Row detail rides on its OWN attribute, exactly like the sidebar's // data-sidebar-detail: every html[data-tab-orientation='vertical'] rule in // styles.css keeps matching both variants untouched, and the gate in app.js // reads one attribute instead of re-parsing localStorage per tab. const previousDetail = root.dataset.tabRailDetail || 'rich'; const detail = (settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich') === 'simple' ? 'simple' : 'rich'; root.dataset.tabRailDetail = detail; // Row ORDER rides on a third attribute, for the same reason detail rides on // its own: a sort flip leaves orientation on 'vertical' both times, and the // order is applied as an inline `order` the render paths emit, not by CSS // that could just re-match. `isTabRailSorted()` (app.js) reads this. const previousSort = root.dataset.tabRailSort || 'activity'; const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity'; root.dataset.tabRailSort = sort; const tabsEl = document.getElementById('sessionTabs'); const rail = document.getElementById('tabRail'); const headerHost = document.getElementById('sessionTabsHost'); if (!sidebarOwnsTabs && tabsEl && rail && headerHost) { if (orientation === 'vertical') { if (tabsEl.parentElement !== rail) rail.appendChild(tabsEl); } else if (tabsEl.parentElement !== headerHost) { headerHost.appendChild(tabsEl); } } if (tabsEl) { tabsEl.setAttribute('aria-orientation', sidebarOwnsTabs || orientation === 'vertical' ? 'vertical' : 'horizontal'); } const settleRailWidth = options.settleRailWidth === true && (orientation === 'vertical' || previous !== orientation); this.applyTabRailWidth?.({ settle: settleRailWidth }); const orientationChanged = previous !== orientation; // A detail flip counts as a change on its own: simple ⟷ detailed leaves the // orientation on 'vertical' both times, and the stamps line is emitted by // the row template, not toggled by CSS — same reasoning as the sidebar's // detail half in applySessionListLayout(). Taller rows also move every // connector anchored to a tab rect. const changed = orientationChanged || previousDetail !== detail || previousSort !== sort; if (orientationChanged) { this.updateTabOverflowMode?.(); if (!settleRailWidth) this.syncTerminalGeometry?.(); } // applyTabWrapSettings() is the ONE owner of tabs-show-folder and is // rail-aware, so it has to run AFTER the two attributes above — the // applySessionListLayout() call that precedes this one on the settings-save // path ran while data-tab-rail-detail still held the old value. It // re-renders by itself when the folder row appears or disappears, which is // why the render below is skipped in that case rather than doubled. const prevTall = this._tallTabsEnabled; if (changed) this.applyTabWrapSettings?.(); if (changed) { // Mirror of applyTabWrapSettings()'s OWN render condition, which is // `prevTallTabs !== undefined && prevTallTabs !== showFolder`: its first // call ever only establishes the baseline and deliberately renders // nothing. Reading an undefined previous value as "it rendered" skips // BOTH renders and leaves the rows stale — reachable whenever this is the // first call, i.e. when the pre-paint script threw and left the // attributes on their fallbacks for applyTabOrientation() to correct. const wrapRendered = prevTall !== undefined && prevTall !== this._tallTabsEnabled; if (!wrapRendered) this._fullRenderSessionTabs?.(); this._updateConnectionLinesImmediate?.(); this._refreshHomeSessionsIfVisible?.(); } // Only detailed rows carry stamps that go stale with no event behind them. // _fullRenderSessionTabs() settles this too, but applyTabOrientation() runs // on paths where nothing re-rendered (boot with the layout already applied). if (this.isRichTabRows?.()) this._startSidebarRichClock?.(); else this._stopSidebarRichClock?.(); }, applyTabWrapSettings() { const settings = this.loadAppSettingsFromStorage(); const defaults = this.getDefaultSettings(); const deviceType = MobileDetection.getDeviceType(); // The left sidebar is one vertical column with its own scroller: there is no // row to wrap into, and its rows are always tall (name + folder) because that // is the cheapest way to tell 25 sessions apart. Header strip keeps the old // rules unchanged. Kept here rather than only in applySessionListLayout() so // that a stray applyTabWrapSettings() call (this one is invoked from // saveAppSettings and from the resize path) cannot leave the sidebar wrapped. // Matches BOTH sidebar variants: isSessionSidebarActive() reads // data-session-list, which applySessionListLayout() sets to 'sidebar' for // 'sidebar' and 'sidebar-rich' alike. Row detail rides on a separate // attribute and has no bearing on wrapping. const sidebar = this.isSessionSidebarActive?.() === true; // Two-row tabs disabled on mobile/tablet — not enough screen space const twoRows = !sidebar && deviceType === 'desktop' ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) : false; // The DETAILED vertical rail is the third tall-row surface, for the same // reason as the sidebar: it is a docked column with a row per session, and // the stamps line below the name says nothing about WHICH project the // session is in. Read from the applied attribute, which applyTabOrientation() // has already written (app.js calls it before this). const railRich = this.isTabRailRich?.() === true; const showFolder = sidebar || twoRows || railRich; const prevTallTabs = this._tallTabsEnabled; this._tallTabsEnabled = showFolder; const tabsEl = document.getElementById('sessionTabs'); if (tabsEl) { tabsEl.classList.toggle('tabs-two-rows', twoRows); tabsEl.classList.toggle('tabs-show-folder', showFolder); } // Re-render tabs if folder visibility changed (folder spans are generated in JS) if (prevTallTabs !== undefined && prevTallTabs !== showFolder) { this._fullRenderSessionTabs(); } }, applyMonitorVisibility() { const settings = this.loadAppSettingsFromStorage(); const defaults = this.getDefaultSettings(); const showMonitor = settings.showMonitor ?? defaults.showMonitor ?? false; const showSubagents = settings.showSubagents ?? defaults.showSubagents ?? false; const showFileBrowser = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; const monitorPanel = document.getElementById('monitorPanel'); if (monitorPanel) { monitorPanel.style.display = showMonitor ? '' : 'none'; if (showMonitor) { monitorPanel.classList.add('open'); } else { monitorPanel.classList.remove('open'); } } const subagentsPanel = document.getElementById('subagentsPanel'); if (subagentsPanel) { if (showSubagents) { subagentsPanel.classList.remove('hidden'); } else { subagentsPanel.classList.add('hidden'); } } // Ultracode agents panel visibility (SYNCED setting — not in displayKeys) const showUltracodeAgents = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false; const ultracodePanel = document.getElementById('ultracodeAgentsPanel'); if (ultracodePanel) { if (showUltracodeAgents) { ultracodePanel.classList.remove('hidden'); } else { ultracodePanel.classList.remove('open'); ultracodePanel.classList.add('hidden'); } } // Floating ultracode run windows have their OWN opt-in (default OFF), independent of the // docked panel above: pop active runs when enabled, tear them all down when disabled // (additional layer — ultracode-windows.js). const ultracodeFloatingWindows = settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false; if (ultracodeFloatingWindows) { if (typeof this.syncAllUltracodeFloatingWindows === 'function') this.syncAllUltracodeFloatingWindows(); } else if (typeof this.removeAllUltracodeWindows === 'function') { this.removeAllUltracodeWindows(); } // File browser panel visibility const fileBrowserPanel = document.getElementById('fileBrowserPanel'); if (fileBrowserPanel) { if (showFileBrowser && this.activeSessionId) { fileBrowserPanel.classList.add('visible'); this.loadFileBrowser(this.activeSessionId); // Attach drag listeners if not already attached if (!this.fileBrowserDragListeners) { const header = fileBrowserPanel.querySelector('.file-browser-header'); if (header) { // Convert right-positioned to left/top before drag so makeWindowDraggable works 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; } } } else if (fileBrowserPanel.classList.contains('visible')) { this._resetFileBrowserForHide?.(); fileBrowserPanel.classList.remove('visible'); } } }, closeMonitor() { // Hide the monitor panel const monitorPanel = document.getElementById('monitorPanel'); if (monitorPanel) { monitorPanel.classList.remove('open'); monitorPanel.style.display = 'none'; } // Save the setting const settings = this.loadAppSettingsFromStorage(); settings.showMonitor = false; this.saveAppSettingsToStorage(settings); }, closeSubagentsPanel() { // Hide the subagents panel const subagentsPanel = document.getElementById('subagentsPanel'); if (subagentsPanel) { subagentsPanel.classList.remove('open'); subagentsPanel.classList.add('hidden'); } this.subagentPanelVisible = false; // Save the setting const settings = this.loadAppSettingsFromStorage(); settings.showSubagents = false; this.saveAppSettingsToStorage(settings); }, async clearAllSubagents() { const count = this.subagents.size; if (count === 0) { this.showToast('No subagents to clear', 'info'); return; } if (!confirm(`Clear all ${count} tracked subagent(s)? This removes them from the UI but does not affect running processes.`)) { return; } try { const res = await fetch('/api/subagents', { method: 'DELETE' }); const data = await res.json(); if (data.success) { // Clear local state this.subagents.clear(); this.subagentActivity.clear(); this.subagentToolResults.clear(); // Close any open subagent windows this.cleanupAllFloatingWindows(); // Update UI this.renderSubagentPanel(); this.renderMonitorSubagents(); this.updateSubagentBadge(); this.showToast(`Cleared ${data.data.cleared} subagent(s)`, 'success'); } else { this.showToast('Failed to clear subagents: ' + data.error, 'error'); } } catch (err) { this.showToast('Failed to clear subagents', 'error'); } }, toggleSubagentsPanel() { const panel = document.getElementById('subagentsPanel'); const toggleBtn = document.getElementById('subagentsToggleBtn'); if (!panel) return; // If hidden, show it first if (panel.classList.contains('hidden')) { panel.classList.remove('hidden'); // Save setting const settings = this.loadAppSettingsFromStorage(); settings.showSubagents = true; this.saveAppSettingsToStorage(settings); } // Toggle open/collapsed state panel.classList.toggle('open'); this.subagentPanelVisible = panel.classList.contains('open'); // Update toggle button icon if (toggleBtn) { toggleBtn.innerHTML = this.subagentPanelVisible ? '▼' : '▲'; // Down when open, up when collapsed } if (this.subagentPanelVisible) { this.renderSubagentPanel(); } }, async loadAppSettingsFromServer(settingsPromise = null) { // One-time migration: showPlanUsageLimits became a per-device display setting. // Before this, it synced from the server, so the (separate) mobile settings blob // may carry a stale `true` the user never enabled on this device. Clear it once // so mobile defaults to OFF; the desktop blob is untouched and keeps its value. try { if ( (MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile') && !localStorage.getItem('codeman:planUsagePerDeviceMigrated') ) { const s = this.loadAppSettingsFromStorage(); if (s && s.showPlanUsageLimits) { s.showPlanUsageLimits = false; this.saveAppSettingsToStorage(s); } localStorage.setItem('codeman:planUsagePerDeviceMigrated', '1'); } } catch { /* best-effort migration */ } try { const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.success === true ? env.data : env); if (settings) { // Extract notification prefs before merging app settings const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings; // Filter out display settings — these are device-specific (mobile vs desktop) // and should not be synced from the server to avoid overriding mobile defaults. // NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled) // are NOT display keys — they control server-side behavior and must sync from server. const displayKeys = new Set([ 'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost', 'showLifecycleLog', 'showResponseViewer', 'showRedrawButton', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled', 'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold', 'language', 'terminalWheelLocalScrollback', 'autoCopySelection', 'copyStripMargin', 'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showTabDetachButton', 'mobileOverviewEnabled', 'sessionLineageLines', 'showSplitButton', ]); // The plan-usage chip is a PER-DEVICE display setting (desktop default ON, // handheld default OFF): desktop can show it while mobile stays hidden. Drop // the server's stored value here so it is NEVER seeded into a device that // didn't explicitly enable it — even though this SAME setting also drives // server-side telemetry collection now (readPlanUsageTelemetryEnabled in // hooks-config.ts), that's a read the server does directly from settings.json // at spawn time; it has nothing to do with what gets merged into THIS // device's local display preference. delete appSettings.showPlanUsageLimits; // Merge settings: non-display keys always sync from server, // display keys only seed from server when localStorage has no value // (prevents cross-device overwrite while fixing settings re-enabling on fresh loads) const localSettings = this.loadAppSettingsFromStorage(); const merged = { ...localSettings }; for (const [key, value] of Object.entries(appSettings)) { if (displayKeys.has(key)) { // Display keys: only use server value as initial seed if (!(key in localSettings)) { merged[key] = value; } } else { // Non-display keys: server always wins merged[key] = value; } } this.saveAppSettingsToStorage(merged); // Apply notification prefs from server if present (only if localStorage has none) if (notificationPreferences && this.notificationManager) { const localNotifPrefs = localStorage.getItem(this.notificationManager.getStorageKey()); if (!localNotifPrefs) { this.notificationManager.preferences = this.notificationManager.normalizePreferences(notificationPreferences); this.notificationManager.savePreferences(); } } // Sync voice settings from server (seed localStorage if no local API key) if (voiceSettings) { const localVoice = localStorage.getItem('codeman-voice-settings'); if (!localVoice || !JSON.parse(localVoice).apiKey) { VoiceInput._saveDeepgramConfig(voiceSettings); } } // Sync respawn presets from server (server is source of truth) if (respawnPresets && Array.isArray(respawnPresets)) { this._serverRespawnPresets = respawnPresets; // Also update localStorage for offline access localStorage.setItem('codeman-respawn-presets', JSON.stringify(respawnPresets)); } else { // Migration: push existing localStorage presets to server const localPresets = localStorage.getItem('codeman-respawn-presets'); if (localPresets) { const parsed = JSON.parse(localPresets); if (parsed.length > 0) { this._serverRespawnPresets = parsed; this._apiPut('/api/settings', { respawnPresets: parsed }).catch(() => {}); } } } // Sync run mode from server if (runMode) { this.runMode = runMode; try { localStorage.setItem('codeman_runMode', runMode); } catch {} this._applyRunMode(); } return merged; } } catch (err) { console.error('Failed to load settings from server:', err); } return this.loadAppSettingsFromStorage(); }, /** * Load subagent window states from server (or localStorage fallback). * Called on page load to restore minimized/open window states. */ async loadSubagentWindowStates() { let states = null; // Try server first for cross-browser sync try { const res = await fetch('/api/subagent-window-states'); if (res.ok) { const env = await res.json(); states = env?.success === true ? env.data : env; // Also update localStorage localStorage.setItem('codeman-subagent-window-states', JSON.stringify(states)); } } catch (err) { console.error('Failed to load subagent window states from server:', err); } // Fallback to localStorage if (!states) { try { const saved = localStorage.getItem('codeman-subagent-window-states'); if (saved) { states = JSON.parse(saved); } } catch (err) { console.error('Failed to load subagent window states from localStorage:', err); } } return states || { minimized: {}, open: [] }; }, // ═══════════════════════════════════════════════════════════════ // Persistent Parent Associations // ═══════════════════════════════════════════════════════════════ // This is the ROCK-SOLID system for tracking which tab an agent belongs to. // Once an agent's parent is discovered, it's saved here PERMANENTLY. /** * Save the subagent parent map to localStorage and server. * Called whenever a new parent association is discovered. */ async saveSubagentParentMap() { const mapData = Object.fromEntries(this.subagentParentMap); // Save to localStorage for instant recovery try { localStorage.setItem('codeman-subagent-parents', JSON.stringify(mapData)); } catch (err) { console.error('Failed to save subagent parents to localStorage:', err); } // Save to server for cross-browser/session persistence try { await fetch('/api/subagent-parents', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mapData) }); } catch (err) { console.error('Failed to save subagent parents to server:', err); } }, /** * Load the subagent parent map from server (or localStorage fallback). * Called once on page load, before any agents are discovered. */ async loadSubagentParentMap() { let mapData = null; // Try server first (most authoritative) try { const res = await fetch('/api/subagent-parents'); if (res.ok) { const env = await res.json(); mapData = env?.success === true ? env.data : env; // Update localStorage as cache localStorage.setItem('codeman-subagent-parents', JSON.stringify(mapData)); } } catch (err) { console.error('Failed to load subagent parents from server:', err); } // Fallback to localStorage if (!mapData) { try { const saved = localStorage.getItem('codeman-subagent-parents'); if (saved) { mapData = JSON.parse(saved); } } catch (err) { console.error('Failed to load subagent parents from localStorage:', err); } } // Populate the map (prune stale entries: require both session and agent to exist) if (mapData && typeof mapData === 'object') { for (const [agentId, sessionId] of Object.entries(mapData)) { if (this.sessions.has(sessionId) && this.subagents.has(agentId)) { this.subagentParentMap.set(agentId, sessionId); } } } }, /** * Get the parent session ID for an agent from the persistent map. * This is the ONLY source of truth for connection lines. */ getAgentParentSessionId(agentId) { return this.subagentParentMap.get(agentId) || null; }, /** * Set and persist the parent session ID for an agent. * Once set, this association is PERMANENT and never recalculated. */ setAgentParentSessionId(agentId, sessionId) { if (!agentId || !sessionId) return; // Only set if not already set (first association wins) if (this.subagentParentMap.has(agentId)) { return; // Already has a parent, don't override } this.subagentParentMap.set(agentId, sessionId); this.saveSubagentParentMap(); // Persist immediately // Also update the agent object for consistency const agent = this.subagents.get(agentId); if (agent) { agent.parentSessionId = sessionId; const session = this.sessions.get(sessionId); if (session) { agent.parentSessionName = this.getSessionName(session); } this.subagents.set(agentId, agent); } }, // ═══════════════════════════════════════════════════════════════ // Help Modal // ═══════════════════════════════════════════════════════════════ showHelp() { const modal = document.getElementById('helpModal'); modal.classList.add('active'); // Activate focus trap this.activeFocusTrap = new FocusTrap(modal); this.activeFocusTrap.activate(); }, closeHelp() { document.getElementById('helpModal').classList.remove('active'); // Deactivate focus trap and restore focus if (this.activeFocusTrap) { this.activeFocusTrap.deactivate(); this.activeFocusTrap = null; } }, // ─── Shortcut Settings (App Settings → Shortcuts tab) ──────────────────────── // Renders the list of shortcuts with capture buttons for key rebinding, // and persists overrides under settings.shortcutOverrides (saved through // saveAppSettingsToStorage so the device key + settings cache stay coherent). renderShortcutSettingsList() { const list = document.getElementById('appSettingsShortcutsList'); if (!list) return; const registry = this.getShortcutRegistry ? this.getShortcutRegistry() : typeof DEFAULT_SHORTCUTS !== 'undefined' ? DEFAULT_SHORTCUTS : []; const overrides = this.readShortcutOverridesFromSettings(); list.innerHTML = registry .map((shortcut) => { const bindingLabel = shortcut.displayBindings ? shortcut.displayBindings.join(' / ') : (shortcut.bindings || []).map((b) => [...(b.modifiers || []), b.key || b.code || ''].join('+')).join(' / '); // Only registry entries dispatched through matchesShortcutEvent() are // configurable; fixed keys (Escape, tab arrows, …) render read-only. const configurable = !!shortcut.action && Array.isArray(shortcut.bindings); const overridden = !!overrides[shortcut.id]; const controls = configurable ? ` ` : ''; return `
${controls}
`; }) .join(''); this._wireShortcutSettingsList(list); }, // Delegated handlers (no inline onclick — registry ids never land inside a // JS string context, and the listeners survive re-renders). _wireShortcutSettingsList(list) { if (list.dataset.shortcutListenersAdded) return; list.dataset.shortcutListenersAdded = 'true'; list.addEventListener('click', (e) => { const btn = e.target?.closest?.('[data-shortcut-action]'); if (!btn) return; const id = btn.closest?.('[data-shortcut-id]')?.dataset?.shortcutId; if (!id) return; if (btn.dataset.shortcutAction === 'capture') this.startShortcutCapture(id); else if (btn.dataset.shortcutAction === 'reset') this.resetShortcutOverride(id); }); list.addEventListener('change', (e) => { const box = e.target; if (!box?.matches?.('[data-shortcut-action="toggle"]')) return; const id = box.closest?.('[data-shortcut-id]')?.dataset?.shortcutId; if (id) this.toggleShortcutEnabled(id, box.checked); }); }, readShortcutOverridesFromSettings() { const settings = this.loadAppSettingsFromStorage(); return settings.shortcutOverrides || {}; }, startShortcutCapture(shortcutId) { const input = document.querySelector(`.shortcut-binding-input[data-id="${shortcutId}"]`); if (!input) return; input.value = 'Press keys…'; input.focus(); this._capturingShortcutId = shortcutId; // Persistent listener (NOT {once}) — the first keydown of a combo like // Ctrl+Shift+P is the modifier itself ('Control'), which must not end the // capture. The first non-modifier key completes it. const onCaptureKeydown = (e) => { e.preventDefault(); e.stopPropagation(); if (e.key === 'Control' || e.key === 'Shift' || e.key === 'Alt' || e.key === 'Meta') return; input.removeEventListener('keydown', onCaptureKeydown); this.onShortcutCaptureKeydown(e, shortcutId); }; input.addEventListener('keydown', onCaptureKeydown); }, onShortcutCaptureKeydown(e, shortcutId) { e.preventDefault(); e.stopPropagation(); this._capturingShortcutId = null; if (e.key === 'Escape') { this.renderShortcutSettingsList(); return; } // Require a real chord: the dispatcher has no focus-target guard, so a // bare-key binding would fire while typing in any input. if (!e.ctrlKey && !e.metaKey && !e.altKey) { this.renderShortcutSettingsList(); this.showToast?.('Shortcut must include Ctrl, Cmd, or Alt', 'error'); return; } const modifiers = []; if (e.ctrlKey) modifiers.push('ctrl'); if (e.metaKey) modifiers.push('meta'); if (e.shiftKey) modifiers.push('shift'); if (e.altKey) modifiers.push('alt'); const settings = this.loadAppSettingsFromStorage(); const shortcutOverrides = { ...(settings.shortcutOverrides || {}) }; shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), bindings: [{ modifiers, key: e.key, code: e.code }], }; settings.shortcutOverrides = shortcutOverrides; this.saveAppSettingsToStorage(settings); this.renderShortcutSettingsList(); }, resetShortcutOverride(shortcutId) { const settings = this.loadAppSettingsFromStorage(); const shortcutOverrides = { ...(settings.shortcutOverrides || {}) }; delete shortcutOverrides[shortcutId]; settings.shortcutOverrides = shortcutOverrides; this.saveAppSettingsToStorage(settings); this.renderShortcutSettingsList(); }, toggleShortcutEnabled(shortcutId, enabled) { const settings = this.loadAppSettingsFromStorage(); const shortcutOverrides = { ...(settings.shortcutOverrides || {}) }; shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled }; settings.shortcutOverrides = shortcutOverrides; this.saveAppSettingsToStorage(settings); this.renderShortcutSettingsList(); }, closeAllPanels() { this.closeSessionOptions(); this.closeAppSettings(); this.cancelCloseSession(); this.closeTokenStats(); document.getElementById('monitorPanel').classList.remove('open'); // Collapse subagents panel (don't hide it permanently) const subagentsPanel = document.getElementById('subagentsPanel'); if (subagentsPanel) { subagentsPanel.classList.remove('open'); } this.subagentPanelVisible = false; }, }); // window.__codemanUser's real role can resolve after settings have already been // opened once (admin-ui.js fetches /api/me asynchronously and dispatches this on // arrival), so the Custom Model Endpoints admin gate needs to be re-applied when // it does, not just when the modal opens. Optional chaining on addEventListener // itself: several frontend tests (run-mode-ui.test.ts) load this file into a vm // context with a minimal fake `document` that has no event-target methods at // all, and a module-level statement that throws there fails the whole file's // evaluation, not just this feature. document.addEventListener?.('codeman:me', () => { window.app?._applyCustomModelAdminGate?.(); window.app?._applyCliManagementAdminGate?.(); });