Files
Codeman/src/web/public/settings-ui.js
T
Codeman maintainer 7fa52cdcd6 fix(review): make WebGL toggle per-device and fix sticky-marker semantics (PR #140)
- saveAppSettings no longer sends webglRendererEnabled on the settings PUT:
  the key is absent from the .strict() SettingsUpdateSchema, so every save
  400'd with INVALID_INPUT, silently killing all server-side settings
  persistence. Stripped in the per-device destructure alongside
  localEchoEnabled/skin/etc.
- shouldSkipWebGL now treats a stored true like the untouched default w.r.t.
  the sticky marker: the checkbox defaults checked on desktop, so any
  unrelated save stored true and every page load then cleared the
  'codeman-webgl-disabled' marker, permanently defeating the GPU-stall
  auto-fallback. Only ?webgl=force clears the marker at init.
- The marker is instead retired on a real OFF->ON toggle flip detected at
  save time (mirrors the _prevGestureEnabled pattern in settings-ui.js).
- webglRendererEnabled added to the displayKeys per-device set in
  loadAppSettingsFromServer (renderer choice is device/GPU-specific; syncing
  would leak mobile's hidden-checkbox false onto desktop).
- Tests: stored true + sticky marker -> still skips WebGL; OFF->ON save
  clears the marker and keeps the key off the wire; default-checked save
  leaves the marker alone; ?webgl=force / ?nowebgl behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:42:36 +02:00

2316 lines
100 KiB
JavaScript

/**
* @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) {
// 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');
},
_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');
},
_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 = `<span class="tunnel-spinner"></span> ${data.message}`;
}
// Also update button text if on welcome screen
const btn = document.getElementById('welcomeTunnelBtn');
if (btn?.classList.contains('connecting')) {
btn.innerHTML = `<span class="tunnel-spinner"></span> ${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;
navigator.serviceWorker.register('/sw.js').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 } = event.data;
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
// ═══════════════════════════════════════════════════════════════
openAppSettings() {
// Load current settings
const settings = this.loadAppSettingsFromStorage();
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 ?? true;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
// 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('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? 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();
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;
// 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
document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked =
settings.codexDangerouslyBypassApprovals ?? false;
// Claude Permissions settings
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? '';
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? '';
// 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();
// 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 ?? true;
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 || 'refactor, endpoint, middleware, callback, async, regex, TypeScript, npm, API, deploy, config, linter, env, webhook, schema, CLI, JSON, CSS, DOM, SSE, backend, frontend, localhost, dependencies, repository, merge, rebase, diff, commit, com';
document.getElementById('voiceInsertMode').value = voiceCfg.insertMode || 'direct';
// Reset key visibility to hidden
const keyInput = document.getElementById('voiceDeepgramKey');
keyInput.type = 'password';
document.getElementById('voiceKeyToggleBtn').textContent = 'Show';
// Update provider status
const providerName = VoiceInput.getActiveProviderName();
const providerEl = document.getElementById('voiceProviderStatus');
providerEl.textContent = providerName;
providerEl.className = 'voice-provider-status' + (providerName.startsWith('Deepgram') ? ' active' : '');
// Updates section — show current version, reset transient result/progress UI.
this._initUpdatesSection();
// Reset to first tab and wire up tab switching
this.switchSettingsTab('settings-display');
const modal = document.getElementById('appSettingsModal');
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.onclick = () => this.switchSettingsTab(btn.dataset.tab);
});
modal.classList.add('active');
// Activate focus trap
this.activeFocusTrap = new FocusTrap(modal);
this.activeFocusTrap.activate();
},
switchSettingsTab(tabName) {
const modal = document.getElementById('appSettingsModal');
// Toggle active class on tab buttons
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
// Toggle hidden class on tab content
modal.querySelectorAll('.modal-tab-content').forEach(content => {
content.classList.toggle('hidden', content.id !== tabName);
});
},
closeAppSettings() {
document.getElementById('appSettingsModal').classList.remove('active');
// Deactivate focus trap and restore focus
if (this.activeFocusTrap) {
this.activeFocusTrap.deactivate();
this.activeFocusTrap = null;
}
},
// ───────────────────────────────────────────────────────────────
// Self-Update (App Settings → Updates). Backend: src/web/self-update.ts.
// ───────────────────────────────────────────────────────────────
/** Friendly label for an in-flight update phase. */
_updatePhaseText(phase) {
return {
queued: 'Queued…',
preparing: 'Preparing…',
stashing: 'Stashing local changes…',
fetching: 'Fetching release…',
checkout: 'Checking out release…',
installing: 'Installing dependencies…',
building: 'Building…',
restarting: 'Restarting Codeman…',
}[phase] || phase;
},
/** Populate the version row and clear transient UI when the modal opens. */
_initUpdatesSection() {
const verEl = this.$('updateCurrentVersion');
if (verEl) verEl.textContent = (this.$('versionDisplay')?.textContent || '').trim() || '—';
for (const id of ['updateResult', 'updateActionRow', 'updateNotes', 'updateProgress']) {
const el = this.$(id);
if (el) el.style.display = 'none';
}
this._updateCheck = null;
},
_setUpdateResult(html) {
const el = this.$('updateResult');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
},
_setUpdateProgress(html) {
const el = this.$('updateProgress');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
},
/** Manual "Check for updates" — asks the server to query GitHub. */
async checkForUpdate() {
const btn = this.$('updateCheckBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Checking…'; }
const data = await this._apiJson('/api/system/update/check');
if (btn) { btn.disabled = false; btn.textContent = 'Check now'; }
const actionRow = this.$('updateActionRow');
const notes = this.$('updateNotes');
if (actionRow) actionRow.style.display = 'none';
if (notes) notes.style.display = 'none';
if (!data) {
this._setUpdateResult('Could not check for updates. Try again later.');
return;
}
this._updateCheck = data;
const verEl = this.$('updateCurrentVersion');
if (verEl && data.currentVersion) verEl.textContent = `v${data.currentVersion}`;
if (data.installKind && data.installKind !== 'git') {
this._setUpdateResult(
`This install can't update itself (${escapeHtml(data.installKind)}). Update with <code>npm i -g aicodeman@latest</code>.`
);
return;
}
if (data.selfUpdateEnabled === false) {
this._setUpdateResult('In-app updates are disabled on this server (CODEMAN_DISABLE_SELF_UPDATE=1).');
return;
}
if (data.error && !data.updateAvailable) {
this._setUpdateResult(escapeHtml(data.error));
return;
}
if (data.updateAvailable && data.latestVersion) {
this._setUpdateResult(
`Update available: <strong>v${escapeHtml(data.latestVersion)}</strong> &nbsp;(current v${escapeHtml(data.currentVersion || '')})`
);
const label = this.$('updateActionLabel');
if (label) label.textContent = `Update to v${data.latestVersion}`;
if (actionRow) actionRow.style.display = 'flex';
const nowBtn = this.$('updateNowBtn');
if (nowBtn) { nowBtn.disabled = false; nowBtn.textContent = 'Update now'; }
if (notes && data.notes) {
notes.style.display = 'block';
notes.textContent = data.notes;
}
} else {
this._setUpdateResult(`You're up to date (v${escapeHtml(data.currentVersion || '')}).`);
}
},
/** Start the update, then poll status across the service restart. */
async startSelfUpdate() {
const target = this._updateCheck?.latestVersion ? `v${this._updateCheck.latestVersion}` : 'the latest release';
if (!confirm(`Update Codeman to ${target}? The server will restart and this page will reload.`)) return;
const btn = this.$('updateNowBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; }
const res = await this._apiPost('/api/system/update', {});
if (!res || !res.ok) {
let msg = 'Failed to start the update.';
try { const j = await res.json(); if (typeof j?.error === 'string' && j.error) msg = j.error; } catch {}
this._setUpdateProgress(`<span style="color:var(--danger,#e5534b)">${escapeHtml(msg)}</span>`);
if (btn) { btn.disabled = false; btn.textContent = 'Update now'; }
return;
}
const actionRow = this.$('updateActionRow');
if (actionRow) actionRow.style.display = 'none';
const notes = this.$('updateNotes');
if (notes) notes.style.display = 'none';
this._setUpdateProgress('Starting update…');
this._pollUpdateStatus();
},
_stopUpdatePolling() {
if (this._updatePollTimer) { clearInterval(this._updatePollTimer); this._updatePollTimer = null; }
},
/**
* Poll the status file every 1.5s. Survives the connection drop while the
* server restarts (fetch throws → "restarting"), then reads the reconciled
* terminal state from the freshly-booted server.
*/
_pollUpdateStatus() {
this._stopUpdatePolling();
const terminal = new Set(['completed', 'completed-needs-manual-restart', 'failed', 'idle']);
const poll = async () => {
let data = null;
try {
const res = await fetch('/api/system/update/status');
if (res.ok) {
const env = await res.json();
data = env && env.success === true ? env.data : env;
}
} catch { /* server restarting — keep polling */ }
if (!data) {
this._setUpdateProgress('↻ Restarting Codeman…');
return;
}
if (!terminal.has(data.phase)) {
// Prefer the live status message — the updater's heartbeat enriches it with
// the latest npm/build output line so a slow step doesn't look frozen — and
// fall back to the static phase label. Append total elapsed so the counter
// keeps ticking between heartbeats: a clear "still working" signal.
const label = (data.message && data.message.trim()) ? data.message.trim() : this._updatePhaseText(data.phase);
let elapsed = '';
if (data.startedAt) {
const secs = Math.max(0, Math.round((Date.now() - data.startedAt) / 1000));
elapsed = ` <span style="color:var(--text-secondary)">· ${secs}s</span>`;
}
this._setUpdateProgress(`<span class="tunnel-spinner"></span> ${escapeHtml(label)}${elapsed}`);
return;
}
this._stopUpdatePolling();
if (data.phase === 'completed') {
let html = `<span style="color:var(--success,#3fb950)">✓ Updated to v${escapeHtml(data.toVersion || '')}. Reloading…</span>`;
if (data.stashRef) {
html += `<br><span style="color:var(--text-secondary)">Local changes stashed as <code>${escapeHtml(data.stashRef)}</code> — run <code>git stash pop</code> to restore.</span>`;
}
this._setUpdateProgress(html);
setTimeout(() => location.reload(), 2500);
} else if (data.phase === 'completed-needs-manual-restart') {
this._setUpdateProgress(
`Update staged. Restart Codeman to apply:<br><code>${escapeHtml(data.manualRestartCommand || 'restart codeman web')}</code>`
);
} else if (data.phase === 'failed') {
let html = `<span style="color:var(--danger,#e5534b)">✗ ${escapeHtml(data.message || 'Update failed')}.</span>`;
if (data.error) html += `<br><span style="color:var(--text-secondary)">${escapeHtml(data.error)}</span>`;
html += `<br><span style="color:var(--text-secondary)">The previous version is still running.</span>`;
if (data.stashRef) {
html += `<br><span style="color:var(--text-secondary)">Local changes stashed as <code>${escapeHtml(data.stashRef)}</code>.</span>`;
}
this._setUpdateProgress(html);
const nowBtn = this.$('updateNowBtn');
const actionRow = this.$('updateActionRow');
if (nowBtn) { nowBtn.disabled = false; nowBtn.textContent = 'Try again'; }
if (actionRow) actionRow.style.display = 'flex';
}
};
poll();
this._updatePollTimer = setInterval(poll, 1500);
},
async loadTunnelStatus() {
try {
const res = await fetch('/api/tunnel/status');
const env = await res.json();
const status = env?.success === true ? env.data : env;
const active = status.running && status.url;
this._tunnelUrl = active ? status.url : null;
this._updateTunnelUrlDisplay(this._tunnelUrl);
this._updateWelcomeTunnelBtn(!!active, this._tunnelUrl);
this._updateTunnelIndicator(!!active);
} catch {
this._tunnelUrl = null;
this._updateTunnelUrlDisplay(null);
this._updateWelcomeTunnelBtn(false);
this._updateTunnelIndicator(false);
}
},
_updateTunnelUrlRow(rowId, displayId, url, suffix = '') {
const row = document.getElementById(rowId);
const display = document.getElementById(displayId);
if (!row || !display) return;
if (url) {
const fullUrl = url + suffix;
row.style.display = '';
display.textContent = fullUrl;
display.onclick = () => {
navigator.clipboard.writeText(fullUrl).then(() => {
this.showToast(`${suffix ? 'Upload' : 'Tunnel'} URL copied`, 'success');
});
};
} else {
row.style.display = 'none';
display.textContent = '';
display.onclick = null;
}
},
_updateTunnelUrlDisplay(url) {
this._updateTunnelUrlRow('tunnelUrlRow', 'tunnelUrlDisplay', url);
this._updateTunnelUrlRow('tunnelUploadUrlRow', 'tunnelUploadUrlDisplay', url, '/upload.html');
},
showTunnelQR() {
// Close existing popup if open
this.closeTunnelQR();
const overlay = document.createElement('div');
overlay.id = 'tunnelQrOverlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:5000;display:flex;align-items:center;justify-content:center;cursor:pointer';
overlay.onclick = (e) => { if (e.target === overlay) this.closeTunnelQR(); };
const card = document.createElement('div');
card.style.cssText = 'background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;text-align:center;max-width:340px;width:90vw;box-shadow:var(--shadow-lg);cursor:default';
card.innerHTML = `
<div style="font-size:14px;font-weight:600;color:var(--text-primary);margin-bottom:16px">Scan to connect</div>
<div id="tunnelQrContainer" style="background:#fff;border-radius:8px;padding:16px;display:inline-block">
<div style="color:#666;font-size:12px">Loading...</div>
</div>
<div id="tunnelQrUrl" style="margin-top:12px;font-family:monospace;font-size:11px;color:var(--text-muted);word-break:break-all;cursor:pointer" title="Click to copy"></div>
<button onclick="app.closeTunnelQR()" style="margin-top:16px;padding:6px 20px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);cursor:pointer;font-size:13px">Close</button>
`;
overlay.appendChild(card);
document.body.appendChild(overlay);
// Fetch QR SVG from server
fetch('/api/tunnel/qr')
.then(res => {
if (!res.ok) throw new Error('Tunnel not running');
return res.json();
})
.then(env => {
const data = env?.success === true ? env.data : env;
const container = document.getElementById('tunnelQrContainer');
if (container && data.svg) container.innerHTML = data.svg;
// Show auth badge, countdown, and regenerate button when auth is enabled
if (data.authEnabled) {
const badge = document.createElement('div');
badge.id = 'tunnelQrBadge';
badge.style.cssText = 'margin-top:8px;font-size:11px;color:var(--text-muted)';
badge.textContent = 'Single-use auth \u00b7 expires in 60s';
const regenBtn = document.createElement('button');
regenBtn.textContent = 'Regenerate QR';
regenBtn.style.cssText = 'margin-top:8px;padding:4px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);cursor:pointer;font-size:11px';
regenBtn.onclick = () => {
fetch('/api/tunnel/qr/regenerate', { method: 'POST' })
.then(() => this.showToast('QR code regenerated', 'success'))
.catch(() => this.showToast('Failed to regenerate QR', 'error'));
};
const card = container.parentElement;
if (card) {
card.appendChild(badge);
card.appendChild(regenBtn);
}
this._resetQrCountdown();
}
})
.catch(() => {
const container = document.getElementById('tunnelQrContainer');
if (container) container.innerHTML = '<div style="color:#c00;font-size:12px;padding:20px">Tunnel not active</div>';
});
// Fetch URL for display
fetch('/api/tunnel/status')
.then(r => r.json())
.then(env => {
const status = env?.success === true ? env.data : env;
const urlEl = document.getElementById('tunnelQrUrl');
if (urlEl && status.url) {
urlEl.textContent = status.url;
urlEl.onclick = () => {
navigator.clipboard.writeText(status.url).then(() => {
this.showToast('Tunnel URL copied', 'success');
});
};
}
})
.catch(() => {});
// Close on Escape
this._tunnelQrEscHandler = (e) => { if (e.key === 'Escape') this.closeTunnelQR(); };
document.addEventListener('keydown', this._tunnelQrEscHandler);
},
closeTunnelQR() {
const overlay = document.getElementById('tunnelQrOverlay');
if (overlay) overlay.remove();
if (this._tunnelQrEscHandler) {
document.removeEventListener('keydown', this._tunnelQrEscHandler);
this._tunnelQrEscHandler = null;
}
this._clearQrCountdown();
},
/** Fallback: fetch QR SVG from API when SSE payload lacks it */
_refreshTunnelQrFromApi() {
fetch('/api/tunnel/qr')
.then(res => res.ok ? res.json() : null)
.then(env => {
const data = env?.success === true ? env.data : env;
if (!data?.svg) return;
const container = document.getElementById('tunnelQrContainer');
if (container) container.innerHTML = data.svg;
const welcomeInner = document.getElementById('welcomeQrInner');
if (welcomeInner) welcomeInner.innerHTML = data.svg;
})
.catch(() => {});
},
/** Start or reset the 60s countdown on the QR badge */
_resetQrCountdown() {
this._clearQrCountdown();
this._qrCountdownSec = 60;
this._updateQrCountdownText();
this._qrCountdownTimer = setInterval(() => {
this._qrCountdownSec--;
if (this._qrCountdownSec <= 0) {
this._clearQrCountdown();
return;
}
this._updateQrCountdownText();
}, 1000);
},
_updateQrCountdownText() {
const badge = document.getElementById('tunnelQrBadge');
if (badge) {
badge.textContent = `Single-use auth \u00b7 expires in ${this._qrCountdownSec}s`;
}
},
_clearQrCountdown() {
if (this._qrCountdownTimer) {
clearInterval(this._qrCountdownTimer);
this._qrCountdownTimer = null;
}
},
async toggleTunnelFromWelcome() {
const btn = document.getElementById('welcomeTunnelBtn');
if (!btn) return;
const isActive = btn.classList.contains('active');
btn.disabled = true;
try {
const newEnabled = !isActive;
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: newEnabled }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (newEnabled && (await this._handleTunnelEnableRefusal(res))) {
this._dismissTunnelConnecting();
this._updateWelcomeTunnelBtn(false);
btn.disabled = false;
return;
}
if (newEnabled) {
this._showTunnelConnecting();
// Poll tunnel status as fallback in case SSE event is missed
this._pollTunnelStatus();
} else {
this._dismissTunnelConnecting();
this.showToast('Tunnel stopped', 'info');
this._updateWelcomeTunnelBtn(false);
btn.disabled = false;
}
} catch (err) {
this._dismissTunnelConnecting();
this.showToast('Failed to toggle tunnel', 'error');
btn.disabled = false;
}
},
_showTunnelConnecting() {
// Remove any existing connecting toast first (without resetting button state)
const oldToast = document.getElementById('tunnelConnectingToast');
if (oldToast) {
oldToast.remove();
}
const btn = document.getElementById('welcomeTunnelBtn');
if (btn) {
btn.classList.add('connecting');
btn.innerHTML = `
<span class="tunnel-spinner"></span>
Connecting...`;
}
// Persistent toast with spinner
const toast = document.createElement('div');
toast.className = 'toast toast-info show';
toast.id = 'tunnelConnectingToast';
toast.innerHTML = '<span class="tunnel-spinner"></span> Cloudflare Tunnel connecting...';
toast.style.pointerEvents = 'auto';
if (!this._toastContainer) {
this._toastContainer = document.querySelector('.toast-container');
if (!this._toastContainer) {
this._toastContainer = document.createElement('div');
this._toastContainer.className = 'toast-container';
document.body.appendChild(this._toastContainer);
}
}
this._toastContainer.appendChild(toast);
},
_dismissTunnelConnecting() {
clearTimeout(this._tunnelPollTimer);
this._tunnelPollTimer = null;
const toast = document.getElementById('tunnelConnectingToast');
if (toast) {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 200);
}
const btn = document.getElementById('welcomeTunnelBtn');
if (btn) btn.classList.remove('connecting');
},
_pollTunnelStatus(attempt = 0) {
if (attempt > 15) return; // give up after ~30s
this._tunnelPollTimer = setTimeout(async () => {
try {
const res = await fetch('/api/tunnel/status');
const env = await res.json();
const status = env?.success === true ? env.data : env;
if (status.running && status.url) {
// Tunnel is up — update UI
this._dismissTunnelConnecting();
this._updateTunnelUrlDisplay(status.url);
const welcomeVisible = document.getElementById('welcomeOverlay')?.classList.contains('visible');
if (welcomeVisible) {
this._updateWelcomeTunnelBtn(true, status.url, true);
this.showToast('Tunnel active', 'success');
} else {
this._updateWelcomeTunnelBtn(true, status.url);
this.showToast(`Tunnel active: ${status.url}`, 'success');
this.showTunnelQR();
}
return;
}
} catch { /* ignore */ }
this._pollTunnelStatus(attempt + 1);
}, 2000);
},
_updateWelcomeTunnelBtn(active, url, firstAppear = false) {
const btn = document.getElementById('welcomeTunnelBtn');
if (btn) {
btn.disabled = false;
if (active) {
btn.classList.remove('connecting');
btn.classList.add('active');
btn.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Tunnel Active`;
} else {
btn.classList.remove('active', 'connecting');
btn.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Cloudflare Tunnel`;
}
}
// Update welcome QR code
const qrWrap = document.getElementById('welcomeQr');
const qrInner = document.getElementById('welcomeQrInner');
const qrUrl = document.getElementById('welcomeQrUrl');
if (!qrWrap || !qrInner) return;
if (active) {
qrWrap.classList.add('visible');
// First appear: start expanded, auto-shrink after 8s
if (firstAppear) {
qrWrap.classList.add('expanded');
clearTimeout(this._welcomeQrShrinkTimer);
this._welcomeQrShrinkTimer = setTimeout(() => {
qrWrap.classList.remove('expanded');
}, 8000);
}
if (url) {
qrUrl.textContent = url;
qrUrl.title = 'Click QR to enlarge';
}
fetch('/api/tunnel/qr')
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(env => { const data = env?.success === true ? env.data : env; if (data.svg) qrInner.innerHTML = data.svg; })
.catch(() => { qrInner.innerHTML = '<div style="color:#999;font-size:11px;padding:20px">QR unavailable</div>'; });
} else {
clearTimeout(this._welcomeQrShrinkTimer);
qrWrap.classList.remove('visible', 'expanded');
qrInner.innerHTML = '';
if (qrUrl) qrUrl.textContent = '';
}
},
toggleWelcomeQrSize() {
const qrWrap = document.getElementById('welcomeQr');
if (qrWrap) {
clearTimeout(this._welcomeQrShrinkTimer);
qrWrap.classList.toggle('expanded');
}
},
// ═══════════════════════════════════════════════════════════════
// Tunnel Header Indicator & Panel (desktop only)
// ═══════════════════════════════════════════════════════════════
_updateTunnelIndicator(active) {
if (MobileDetection.getDeviceType() === 'mobile') return;
const indicator = document.getElementById('tunnelIndicator');
if (!indicator) return;
indicator.style.display = active ? 'flex' : 'none';
indicator.classList.remove('connecting');
},
toggleTunnelPanel() {
const existing = document.getElementById('tunnelPanel');
if (existing) {
this.closeTunnelPanel();
return;
}
this._openTunnelPanel();
},
async _openTunnelPanel() {
const panel = document.createElement('div');
panel.className = 'tunnel-panel';
panel.id = 'tunnelPanel';
panel.innerHTML = `
<div class="tunnel-panel-header">
<h3>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Cloudflare Tunnel
<span class="tunnel-panel-status" id="tunnelPanelStatus">Loading...</span>
</h3>
</div>
<div class="tunnel-panel-body" id="tunnelPanelBody">
<div style="font-size:12px;color:var(--text-muted);padding:8px 0">Loading...</div>
</div>
`;
document.body.appendChild(panel);
// Close on outside click
this._tunnelPanelClickHandler = (e) => {
if (!panel.contains(e.target) && e.target.id !== 'tunnelIndicator' && !e.target.closest('.tunnel-indicator')) {
this.closeTunnelPanel();
}
};
setTimeout(() => document.addEventListener('click', this._tunnelPanelClickHandler), 0);
// Close on Escape
this._tunnelPanelEscHandler = (e) => { if (e.key === 'Escape') this.closeTunnelPanel(); };
document.addEventListener('keydown', this._tunnelPanelEscHandler);
// Fetch tunnel info
try {
const res = await fetch('/api/tunnel/info');
const env = await res.json();
const info = env?.success === true ? env.data : env;
this._renderTunnelPanel(info);
} catch {
const body = document.getElementById('tunnelPanelBody');
if (body) body.innerHTML = '<div style="font-size:12px;color:var(--red);padding:8px 0">Failed to load tunnel info</div>';
}
},
_renderTunnelPanel(info) {
const statusEl = document.getElementById('tunnelPanelStatus');
const body = document.getElementById('tunnelPanelBody');
if (!statusEl || !body) return;
statusEl.textContent = info.running ? 'Connected' : 'Offline';
statusEl.className = 'tunnel-panel-status' + (info.running ? '' : ' offline');
let html = '';
// URL section
if (info.url) {
html += `
<div class="tunnel-panel-section">
<div class="tunnel-panel-label">URL</div>
<div class="tunnel-panel-url" id="tunnelPanelUrl" title="Click to copy">${escapeHtml(info.url)}</div>
</div>`;
}
// Clients section
html += `
<div class="tunnel-panel-section">
<div class="tunnel-panel-label">Connections</div>
<div class="tunnel-panel-stat">
<span>Remote Clients</span>
<span class="tunnel-panel-stat-value">${info.sseClients}</span>
</div>`;
if (info.authEnabled) {
html += `
<div class="tunnel-panel-stat">
<span>Auth Sessions</span>
<span class="tunnel-panel-stat-value">${info.authSessions.length}</span>
</div>`;
}
html += '</div>';
// Auth sessions detail
if (info.authEnabled && info.authSessions.length > 0) {
html += '<div class="tunnel-panel-section"><div class="tunnel-panel-label">Authenticated Devices</div>';
for (const s of info.authSessions) {
const ua = s.ua || 'Unknown';
const browser = ua.match(/Chrome|Firefox|Safari|Edge|Mobile/)?.[0] || 'Browser';
const ago = this._formatTimeAgo(s.createdAt);
html += `
<div class="tunnel-panel-session">
<span class="tunnel-panel-session-dot"></span>
<span class="tunnel-panel-session-info" title="${escapeHtml(ua)}">${escapeHtml(browser)} &middot; ${escapeHtml(s.ip)} &middot; ${ago}</span>
<span class="tunnel-panel-session-method">${s.method}</span>
</div>`;
}
html += '</div>';
}
// Actions
html += '<div class="tunnel-panel-actions">';
if (info.running) {
html += `
<button class="tunnel-panel-btn btn-qr" onclick="app.showTunnelQR();app.closeTunnelPanel()">QR Code</button>
<button class="tunnel-panel-btn btn-stop" onclick="app._tunnelPanelToggle(false)">Stop Tunnel</button>`;
} else {
html += `<button class="tunnel-panel-btn btn-start" onclick="app._tunnelPanelToggle(true)">Start Tunnel</button>`;
}
html += '</div>';
// Revoke all sessions button
if (info.authEnabled && info.authSessions.length > 0) {
html += `
<div style="padding-top:8px">
<button class="tunnel-panel-btn btn-revoke" style="width:100%" onclick="app._tunnelPanelRevokeAll()">Revoke All Sessions</button>
</div>`;
}
body.innerHTML = html;
// Bind URL copy handler
const urlEl = document.getElementById('tunnelPanelUrl');
if (urlEl) {
urlEl.onclick = () => {
navigator.clipboard.writeText(info.url).then(() => this.showToast('Tunnel URL copied', 'success'));
};
}
},
_formatTimeAgo(timestamp) {
const diff = Date.now() - timestamp;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
},
/**
* COD-55: detect the server's refusal to start an unauthenticated public tunnel.
* The PUT /api/settings route returns a 4xx with { success:false, error } when no
* CODEMAN_PASSWORD is set and the unauthenticated-network opt-in is not acknowledged.
* Shows the server's (actionable) message as an error toast.
* @param {Response|null} res - the fetch Response from the settings PUT
* @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort)
*/
async _handleTunnelEnableRefusal(res) {
if (!res || res.ok) return false;
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
try {
const body = await res.json();
if (body && body.error) message = body.error;
} catch {
/* non-JSON body — use the default message */
}
this._dismissTunnelConnecting?.();
this.showToast(message, 'error');
return true;
},
async _tunnelPanelToggle(enable) {
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: enable }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (enable && (await this._handleTunnelEnableRefusal(res))) {
this.closeTunnelPanel();
return;
}
if (enable) {
this._updateTunnelIndicator(false);
const indicator = document.getElementById('tunnelIndicator');
if (indicator) {
indicator.style.display = 'flex';
indicator.classList.add('connecting');
}
this.showToast('Tunnel starting...', 'info');
this._showTunnelConnecting();
this._pollTunnelStatus();
} else {
this.showToast('Tunnel stopped', 'info');
}
this.closeTunnelPanel();
} catch {
this.showToast('Failed to toggle tunnel', 'error');
}
},
async _tunnelPanelRevokeAll() {
try {
await fetch('/api/auth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
this.showToast('All sessions revoked', 'success');
// Refresh panel
const res = await fetch('/api/tunnel/info');
const env = await res.json();
const info = env?.success === true ? env.data : env;
this._renderTunnelPanel(info);
} catch {
this.showToast('Failed to revoke sessions', 'error');
}
},
closeTunnelPanel() {
const panel = document.getElementById('tunnelPanel');
if (panel) panel.remove();
if (this._tunnelPanelClickHandler) {
document.removeEventListener('click', this._tunnelPanelClickHandler);
this._tunnelPanelClickHandler = null;
}
if (this._tunnelPanelEscHandler) {
document.removeEventListener('keydown', this._tunnelPanelEscHandler);
this._tunnelPanelEscHandler = null;
}
},
toggleDeepgramKeyVisibility() {
const input = document.getElementById('voiceDeepgramKey');
const btn = document.getElementById('voiceKeyToggleBtn');
if (input.type === 'password') {
input.type = 'text';
btn.textContent = 'Hide';
} else {
input.type = 'password';
btn.textContent = 'Show';
}
},
// ═══════════════════════════════════════════════════════════════
// Session Lifecycle Log
// ═══════════════════════════════════════════════════════════════
openLifecycleLog() {
const win = document.getElementById('lifecycleWindow');
win.style.display = 'block';
// Reset transform so it appears centered initially
if (!win._dragInitialized) {
win.style.left = '50%';
win.style.transform = 'translateX(-50%)';
this._initLifecycleDrag(win);
win._dragInitialized = true;
}
this.loadLifecycleLog();
},
closeLifecycleLog() {
document.getElementById('lifecycleWindow').style.display = 'none';
},
_initLifecycleDrag(win) {
const header = document.getElementById('lifecycleWindowHeader');
let isDragging = false;
let startX, startY, startLeft, startTop;
header.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'SELECT' || e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return;
isDragging = true;
// Clear transform so left/top work in absolute pixels
const rect = win.getBoundingClientRect();
win.style.transform = 'none';
win.style.left = rect.left + 'px';
win.style.top = rect.top + 'px';
startX = e.clientX;
startY = e.clientY;
startLeft = rect.left;
startTop = rect.top;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
win.style.left = (startLeft + e.clientX - startX) + 'px';
win.style.top = (startTop + e.clientY - startY) + 'px';
});
document.addEventListener('mouseup', () => {
isDragging = false;
});
},
async loadLifecycleLog() {
const eventFilter = document.getElementById('lifecycleFilterEvent').value;
const sessionFilter = document.getElementById('lifecycleFilterSession').value.trim();
const params = new URLSearchParams();
if (eventFilter) params.set('event', eventFilter);
if (sessionFilter) params.set('sessionId', sessionFilter);
params.set('limit', '300');
try {
const res = await fetch(`/api/session-lifecycle?${params}`);
const env = await res.json();
const data = env?.success === true ? env.data : env;
const tbody = document.getElementById('lifecycleTableBody');
const empty = document.getElementById('lifecycleEmpty');
if (!data.entries || data.entries.length === 0) {
tbody.innerHTML = '';
empty.style.display = '';
return;
}
empty.style.display = 'none';
const eventColors = {
created: '#4ade80', started: '#4ade80', recovered: '#4ade80',
exit: '#fbbf24', mux_died: '#f87171', deleted: '#f87171', stale_cleaned: '#f87171',
server_started: '#666', server_stopped: '#666',
};
tbody.innerHTML = data.entries.map(e => {
const time = new Date(e.ts).toLocaleString();
const color = eventColors[e.event] || '#888';
const name = e.name || (e.sessionId === '*' ? '—' : this.getShortId(e.sessionId));
const extra = [];
if (e.exitCode !== undefined && e.exitCode !== null) extra.push(`code=${e.exitCode}`);
if (e.mode) extra.push(e.mode);
return `<tr style="border-bottom:1px solid #1a1a2e">
<td style="padding:3px 8px;color:#888;white-space:nowrap">${time}</td>
<td style="padding:3px 8px;color:${color};font-weight:600">${e.event}</td>
<td style="padding:3px 8px;color:#e0e0e0" title="${e.sessionId}">${name}</td>
<td style="padding:3px 8px;color:#aaa">${e.reason || ''}</td>
<td style="padding:3px 8px;color:#666">${extra.join(', ')}</td>
</tr>`;
}).join('');
} catch (err) {
console.error('Failed to load lifecycle log:', err);
}
},
async saveAppSettings() {
// Gesture overlay is injected at page render (server-side), so a change to it
// only takes effect on reload — remember the prior value to decide below.
const _prev = this.loadAppSettingsFromStorage();
const _prevGestureEnabled = (_prev.gestureControlEnabled ?? false) === true;
// WebGL toggle: default ON (desktop), so only an explicit stored false counts
// as "previously off" — used below to detect a real OFF→ON flip.
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
const settings = {
defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(),
defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(),
ralphTrackerEnabled: document.getElementById('appSettingsRalphEnabled').checked,
// Header visibility settings
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked,
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
skin: document.getElementById('appSettingsSkin').value,
// Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value,
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
// Codex CLI settings
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
// Claude Permissions settings
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
claudeModel: document.getElementById('appSettingsClaudeModel').value,
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
thinkingEffort: document.getElementById('appSettingsThinkingEffort').value,
// CPU Priority settings
nice: {
enabled: document.getElementById('appSettingsNiceEnabled').checked,
niceValue: parseInt(document.getElementById('appSettingsNiceValue').value) || 10,
},
};
// The "Token Count" / "Show Cost ($)" header toggles were removed from the
// UI, but their features still read settings.showTokenCount / settings.showCost
// (applyHeaderVisibilitySettings, the header cost render). saveAppSettings
// rebuilds `settings` fresh from the DOM (a full replacement, not a merge), so
// without this these keys would be DROPPED on every save and fall back to their
// defaults — silently re-enabling the token chip for anyone who'd turned it off,
// with no UI left to turn it back off. Preserve the prior stored preference.
if (_prev.showTokenCount !== undefined) settings.showTokenCount = _prev.showTokenCount;
if (_prev.showCost !== undefined) settings.showCost = _prev.showCost;
// Save to localStorage
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
// A real OFF→ON flip of the WebGL toggle retires the GPU-stall auto-fallback
// marker so the next reload actually re-tries WebGL. Only the transition
// clears it — an incidental save with the checkbox default-checked must NOT
// defeat the sticky safety net (shouldSkipWebGL treats stored true like the
// untouched default at page load).
if (!_prevWebglEnabled && settings.webglRendererEnabled) {
try { localStorage.removeItem('codeman-webgl-disabled'); } catch {}
}
// Save voice settings to localStorage + include in server payload for cross-device sync
const voiceSettings = {
apiKey: document.getElementById('voiceDeepgramKey').value.trim(),
language: document.getElementById('voiceLanguage').value,
keyterms: document.getElementById('voiceKeyterms').value.trim(),
insertMode: document.getElementById('voiceInsertMode').value,
};
VoiceInput._saveDeepgramConfig(voiceSettings);
// Save notification preferences separately
const notifPrefsToSave = {
enabled: document.getElementById('appSettingsNotifEnabled').checked,
browserNotifications: document.getElementById('appSettingsNotifBrowser').checked,
audioAlerts: document.getElementById('appSettingsNotifAudio').checked,
stuckThresholdMs: (parseInt(document.getElementById('appSettingsNotifStuckMins').value) || 10) * 60000,
muteCritical: !document.getElementById('appSettingsNotifCritical').checked,
muteWarning: !document.getElementById('appSettingsNotifWarning').checked,
muteInfo: !document.getElementById('appSettingsNotifInfo').checked,
// Per-event-type preferences
eventTypes: {
permission_prompt: {
enabled: document.getElementById('eventPermissionEnabled').checked,
browser: document.getElementById('eventPermissionBrowser').checked,
push: document.getElementById('eventPermissionPush').checked,
audio: document.getElementById('eventPermissionAudio').checked,
},
elicitation_dialog: {
enabled: document.getElementById('eventQuestionEnabled').checked,
browser: document.getElementById('eventQuestionBrowser').checked,
push: document.getElementById('eventQuestionPush').checked,
audio: document.getElementById('eventQuestionAudio').checked,
},
idle_prompt: {
enabled: document.getElementById('eventIdleEnabled').checked,
browser: document.getElementById('eventIdleBrowser').checked,
push: document.getElementById('eventIdlePush').checked,
audio: document.getElementById('eventIdleAudio').checked,
},
stop: {
enabled: document.getElementById('eventStopEnabled').checked,
browser: document.getElementById('eventStopBrowser').checked,
push: document.getElementById('eventStopPush').checked,
audio: document.getElementById('eventStopAudio').checked,
},
session_error: {
enabled: true,
browser: this.notificationManager?.preferences?.eventTypes?.session_error?.browser ?? true,
push: this.notificationManager?.preferences?.eventTypes?.session_error?.push ?? false,
audio: false,
},
respawn_cycle: {
enabled: document.getElementById('eventRespawnEnabled').checked,
browser: document.getElementById('eventRespawnBrowser').checked,
push: document.getElementById('eventRespawnPush').checked,
audio: document.getElementById('eventRespawnAudio').checked,
},
token_milestone: {
enabled: true,
browser: false,
push: false,
audio: false,
},
ralph_complete: {
enabled: document.getElementById('eventRalphEnabled').checked,
browser: document.getElementById('eventRalphBrowser').checked,
push: document.getElementById('eventRalphPush').checked,
audio: document.getElementById('eventRalphAudio').checked,
},
subagent_spawn: {
enabled: document.getElementById('eventSubagentEnabled').checked,
browser: document.getElementById('eventSubagentBrowser').checked,
push: document.getElementById('eventSubagentPush').checked,
audio: document.getElementById('eventSubagentAudio').checked,
},
subagent_complete: {
enabled: document.getElementById('eventSubagentEnabled').checked,
browser: document.getElementById('eventSubagentBrowser').checked,
push: document.getElementById('eventSubagentPush').checked,
audio: document.getElementById('eventSubagentAudio').checked,
},
},
_version: 4,
};
if (this.notificationManager) {
this.notificationManager.preferences = notifPrefsToSave;
this.notificationManager.savePreferences();
}
// Sync push preferences to server
this._syncPushPreferences();
// Apply header visibility immediately
this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyTabWrapSettings();
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
this.applyMonitorVisibility();
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting
// Apply CJK input visibility immediately
this._updateCjkInputState();
// Apply keyboard bar mode
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
// Save to server (includes notification prefs for cross-browser persistence).
// Strip device-specific DISPLAY keys so they never sync across devices —
// localEcho/cjk/extendedKeyboard/skin are per-platform, and showPlanUsageLimits
// is per-device too (desktop can show the usage chip while mobile stays hidden).
// webglRendererEnabled is per-device as well (renderer choice is GPU-specific,
// and syncing would leak mobile's hidden-checkbox false onto desktop); it's
// also absent from SettingsUpdateSchema, which is .strict() — sending it
// would 400 the whole settings PUT.
// Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on
// ENABLE only, so a device with the chip OFF never strips the exporter that
// another device's chip depends on — see system-routes settings handler).
const {
localEchoEnabled: _leo,
cjkInputEnabled: _cjk,
extendedKeyboardBar: _ekb,
skin: _skin,
showPlanUsageLimits: _pul,
showAttachmentsButton: _ahb,
webglRendererEnabled: _wgl,
...serverSettings
} = settings;
try {
const res = await this._apiPut('/api/settings', {
...serverSettings,
...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}),
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
// (in the UI + localStorage) so it doesn't look enabled. Other settings persisted
// to localStorage above still apply locally.
if (settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
settings.tunnelEnabled = false;
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
if (cb) cb.checked = false;
this.closeAppSettings();
return;
}
// Save model configuration separately
await this.saveModelConfigFromSettings();
this.showToast('Settings saved', 'success');
// Show tunnel-specific feedback if toggled on
if (settings.tunnelEnabled) {
this.showToast('Tunnel starting — QR code will appear when ready...', 'info');
}
} catch (err) {
// Server save failed but localStorage succeeded
this.showToast('Settings saved locally', 'warning');
}
this.closeAppSettings();
// The gesture overlay is injected at page render (server reads
// gestureControlEnabled from settings.json), so a change only takes effect on
// reload. Reload when it actually changed — the server PUT above already
// persisted the new value.
if (settings.gestureControlEnabled !== _prevGestureEnabled) {
this.showToast(
settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…',
'info'
);
setTimeout(() => location.reload(), 400);
}
},
// Load model configuration from server for the settings modal
async loadModelConfigForSettings() {
try {
const res = await fetch('/api/execution/model-config');
const data = await res.json();
if (data.success && data.data) {
const config = data.data;
// Default model
const defaultModelEl = document.getElementById('appSettingsDefaultModel');
if (defaultModelEl) {
defaultModelEl.value = config.defaultModel || '';
}
// Show recommendations
const showRecsEl = document.getElementById('appSettingsShowModelRecommendations');
if (showRecsEl) {
showRecsEl.checked = config.showRecommendations ?? true;
}
// Agent type overrides
const overrides = config.agentTypeOverrides || {};
const exploreEl = document.getElementById('appSettingsModelExplore');
const implementEl = document.getElementById('appSettingsModelImplement');
const testEl = document.getElementById('appSettingsModelTest');
const reviewEl = document.getElementById('appSettingsModelReview');
if (exploreEl) exploreEl.value = overrides.explore || '';
if (implementEl) implementEl.value = overrides.implement || '';
if (testEl) testEl.value = overrides.test || '';
if (reviewEl) reviewEl.value = overrides.review || '';
}
} catch (err) {
console.warn('Failed to load model config:', err);
}
},
// Save model configuration from settings modal to server
async saveModelConfigFromSettings() {
const defaultModelEl = document.getElementById('appSettingsDefaultModel');
const showRecsEl = document.getElementById('appSettingsShowModelRecommendations');
const exploreEl = document.getElementById('appSettingsModelExplore');
const implementEl = document.getElementById('appSettingsModelImplement');
const testEl = document.getElementById('appSettingsModelTest');
const reviewEl = document.getElementById('appSettingsModelReview');
const agentTypeOverrides = {};
if (exploreEl?.value) agentTypeOverrides.explore = exploreEl.value;
if (implementEl?.value) agentTypeOverrides.implement = implementEl.value;
if (testEl?.value) agentTypeOverrides.test = testEl.value;
if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value;
const config = {
defaultModel: defaultModelEl?.value || '',
showRecommendations: showRecsEl?.checked ?? true,
agentTypeOverrides,
};
try {
await fetch('/api/execution/model-config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
} catch (err) {
console.warn('Failed to save model config:', err);
}
},
// ═══════════════════════════════════════════════════════════════
// Visibility Settings & Device-Specific Defaults
// ═══════════════════════════════════════════════════════════════
// Get the global Ralph tracker enabled setting
isRalphTrackerEnabledByDefault() {
const settings = this.loadAppSettingsFromStorage();
return settings.ralphTrackerEnabled ?? false;
},
// Get the settings storage key based on device type (mobile vs desktop)
getSettingsStorageKey() {
const isMobile = MobileDetection.getDeviceType() === 'mobile';
return isMobile ? 'codeman-app-settings-mobile' : 'codeman-app-settings';
},
// Get default settings based on device type
// Note: Notification prefs are handled separately by NotificationManager
getDefaultSettings() {
const isMobile = MobileDetection.getDeviceType() === 'mobile';
if (isMobile) {
// 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,
showMultiMonitorButton: false,
showPlanUsageLimits: false,
showAttachmentsButton: false,
// 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,
cjkInputEnabled: false,
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;
try {
localStorage.setItem('codeman:skin', skin);
} catch (_e) {
/* private mode */
}
if (typeof this.applyTerminalSkin === 'function') this.applyTerminalSkin(skin);
},
applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
const showSystemStats = compactHeader ? false : (settings.showSystemStats ?? defaults.showSystemStats ?? true);
const showTokenCount = compactHeader ? false : (settings.showTokenCount ?? defaults.showTokenCount ?? true);
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
const showLifecycleLog = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
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);
}
// 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);
}
// Plan-usage chip — hidden by default (App Settings → Display → "Plan Usage
// Limits"). Server renders the initial state on reload; this handles a live
// toggle from a settings save. Marker class (base is display:inline-flex
// !important), matching the response-viewer/multimonitor pattern.
const showPlanUsageLimits = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
const planUsageChip = document.getElementById('planUsageChip');
if (planUsageChip) {
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
}
// 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');
}
},
applyTabWrapSettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const deviceType = MobileDetection.getDeviceType();
// Two-row tabs disabled on mobile/tablet — not enough screen space
const twoRows = deviceType === 'desktop'
? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false)
: false;
const prevTallTabs = this._tallTabsEnabled;
this._tallTabsEnabled = twoRows;
const tabsEl = document.getElementById('sessionTabs');
if (tabsEl) {
tabsEl.classList.toggle('tabs-two-rows', twoRows);
tabsEl.classList.toggle('tabs-show-folder', twoRows);
}
// Re-render tabs if folder visibility changed (folder spans are generated in JS)
if (prevTallTabs !== undefined && prevTallTabs !== twoRows) {
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');
}
}
// 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 {
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 ? '&#x25BC;' : '&#x25B2;'; // 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.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',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'webglRendererEnabled',
]);
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
// can show it while mobile stays hidden. It used to sync, so an older
// server.json may still carry `true` — drop it so the server value is NEVER
// seeded into a device that didn't explicitly enable it (collection is handled
// separately via the statusLineTelemetry action, not this display flag).
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 = 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;
}
},
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;
},
});