2026-03-14 21:24:22 +01:00
/**
* @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' );
}
2026-03-26 22:50:25 +01:00
this . _notifySession ( data . sessionId , 'warning' , 'hook-idle' , 'Waiting for Input' , data . message || 'Claude is idle and waiting for a prompt' );
2026-03-14 21:24:22 +01:00
},
_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 : '' } ` : '' ;
2026-03-26 22:50:25 +01:00
this . _notifySession ( data . sessionId , 'critical' , 'hook-permission' , 'Permission Required' , toolInfo || 'Claude needs tool approval to continue' );
2026-03-14 21:24:22 +01:00
},
_onHookElicitationDialog ( data ) {
// Always track pending hook - action alerts need user interaction to clear
if ( data . sessionId ) {
this . setPendingHook ( data . sessionId , 'elicitation_dialog' );
}
2026-03-26 22:50:25 +01:00
this . _notifySession ( data . sessionId , 'critical' , 'hook-elicitation' , 'Question Asked' , data . question || 'Claude is asking a question and waiting for your answer' );
2026-03-14 21:24:22 +01:00
},
_onHookStop ( data ) {
// Clear all pending hooks when Claude finishes responding
if ( data . sessionId ) {
this . clearPendingHooks ( data . sessionId );
}
2026-03-26 22:50:25 +01:00
this . _notifySession ( data . sessionId , 'info' , 'hook-stop' , 'Response Complete' , data . reason || 'Claude has finished responding' );
2026-03-14 21:24:22 +01:00
},
_onHookTeammateIdle ( data ) {
const session = this . sessions . get ( data . sessionId );
2026-03-26 22:50:25 +01:00
this . _notifySession ( data . sessionId , 'warning' , 'hook-teammate-idle' , 'Teammate Idle' , `A teammate is idle in ${ session ? . name || data . sessionId } ` );
2026-03-14 21:24:22 +01:00
},
_onHookTaskCompleted ( data ) {
const session = this . sessions . get ( data . sessionId );
2026-03-26 22:50:25 +01:00
this . _notifySession ( data . sessionId , 'info' , 'hook-task-completed' , 'Task Completed' , `A team task completed in ${ session ? . name || data . sessionId } ` );
2026-03-14 21:24:22 +01:00
},
// 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' );
2026-06-10 01:30:43 +02:00
if ( ! keyData ) throw new Error ( 'Failed to get VAPID key' );
2026-03-14 21:24:22 +01:00
2026-06-10 01:30:43 +02:00
const applicationServerKey = urlBase64ToUint8Array ( keyData . publicKey );
2026-03-14 21:24:22 +01:00
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 (),
},
});
2026-06-10 01:30:43 +02:00
if ( ! data ) throw new Error ( 'Failed to register subscription' );
2026-03-14 21:24:22 +01:00
this . _pushSubscription = subscription ;
2026-06-10 01:30:43 +02:00
this . _pushSubscriptionId = data . id ;
localStorage . setItem ( 'codeman-push-subscription-id' , data . id );
2026-03-14 21:24:22 +01:00
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 ;
2026-06-10 12:16:47 +02:00
document . getElementById ( 'appSettingsShowResponseViewer' ). checked = settings . showResponseViewer ?? defaults . showResponseViewer ?? false ;
2026-06-14 21:21:06 +02:00
document . getElementById ( 'appSettingsShowAttachmentsButton' ). checked = settings . showAttachmentsButton ?? defaults . showAttachmentsButton ?? false ;
2026-06-13 19:46:50 +02:00
document . getElementById ( 'appSettingsSkin' ). value = settings . skin ?? defaults . skin ?? 'daylight-blue' ;
2026-07-01 20:41:38 -05:00
// 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' ;
2026-06-10 11:12:19 +02:00
document . getElementById ( 'appSettingsShowMonitor' ). checked = settings . showMonitor ?? defaults . showMonitor ?? false ;
2026-03-14 21:24:22 +01:00
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 ;
2026-06-08 06:01:15 +02:00
document . getElementById ( 'appSettingsShowMultiMonitorButton' ). checked = settings . showMultiMonitorButton ?? defaults . showMultiMonitorButton ?? false ;
2026-06-14 05:00:28 +02:00
document . getElementById ( 'appSettingsShowPlanUsageLimits' ). checked = settings . showPlanUsageLimits ?? defaults . showPlanUsageLimits ?? false ;
2026-06-08 17:01:17 +02:00
// 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' ;
2026-06-08 06:01:15 +02:00
document . getElementById ( 'appSettingsGestureControl' ). checked = settings . gestureControlEnabled ?? defaults . gestureControlEnabled ?? false ;
2026-03-14 21:24:22 +01:00
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 ();
2026-06-09 10:26:09 -04:00
document . getElementById ( 'appSettingsCjkInput' ). checked = settings . cjkInputEnabled ?? defaults . cjkInputEnabled ?? false ;
2026-04-03 04:17:33 +02:00
document . getElementById ( 'appSettingsExtendedKeyboardBar' ). checked = settings . extendedKeyboardBar ?? false ;
2026-03-14 21:24:22 +01:00
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' ;
};
2026-06-10 09:31:58 -04:00
// Codex CLI settings
document . getElementById ( 'appSettingsCodexDangerouslyBypassApprovals' ). checked =
settings . codexDangerouslyBypassApprovals ?? false ;
2026-03-14 21:24:22 +01:00
// Claude Permissions settings
document . getElementById ( 'appSettingsAgentTeams' ). checked = settings . agentTeamsEnabled ?? false ;
2026-06-10 16:47:05 +02:00
document . getElementById ( 'appSettingsClaudeModel' ). value = settings . claudeModel ?? '' ;
2026-03-28 16:49:55 +01:00
document . getElementById ( 'appSettingsOpusContext1m' ). checked = settings . opusContext1mEnabled ?? false ;
2026-04-16 15:54:59 +08:00
document . getElementById ( 'appSettingsThinkingEffort' ). value = settings . thinkingEffort ?? '' ;
2026-03-14 21:24:22 +01:00
// 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' : '' );
2026-06-09 01:55:54 +02:00
// Updates section — show current version, reset transient result/progress UI.
this . _initUpdatesSection ();
2026-03-14 21:24:22 +01:00
// 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 ;
}
},
2026-06-09 01:55:54 +02:00
// ───────────────────────────────────────────────────────────────
// 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> (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.' ;
2026-06-10 03:41:04 +02:00
try { const j = await res . json (); if ( typeof j ? . error === 'string' && j . error ) msg = j . error ; } catch {}
2026-06-09 01:55:54 +02:00
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' );
2026-06-10 01:30:43 +02:00
if ( res . ok ) {
const env = await res . json ();
data = env && env . success === true ? env . data : env ;
}
2026-06-09 01:55:54 +02:00
} catch { /* server restarting — keep polling */ }
if ( ! data ) {
this . _setUpdateProgress ( '↻ Restarting Codeman…' );
return ;
}
if ( ! terminal . has ( data . phase )) {
2026-06-09 03:36:39 +02:00
// 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 } ` );
2026-06-09 01:55:54 +02:00
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 );
},
2026-03-14 21:24:22 +01:00
async loadTunnelStatus () {
try {
const res = await fetch ( '/api/tunnel/status' );
2026-06-10 01:30:43 +02:00
const env = await res . json ();
const status = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
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 );
}
},
2026-03-26 22:50:25 +01:00
_updateTunnelUrlRow ( rowId , displayId , url , suffix = '' ) {
const row = document . getElementById ( rowId );
const display = document . getElementById ( displayId );
2026-03-14 21:24:22 +01:00
if ( ! row || ! display ) return ;
if ( url ) {
2026-03-26 22:50:25 +01:00
const fullUrl = url + suffix ;
2026-03-14 21:24:22 +01:00
row . style . display = '' ;
2026-03-26 22:50:25 +01:00
display . textContent = fullUrl ;
2026-03-14 21:24:22 +01:00
display . onclick = () => {
2026-03-26 22:50:25 +01:00
navigator . clipboard . writeText ( fullUrl ). then (() => {
this . showToast ( ` ${ suffix ? 'Upload' : 'Tunnel' } URL copied` , 'success' );
2026-03-14 21:24:22 +01:00
});
};
} else {
row . style . display = 'none' ;
display . textContent = '' ;
display . onclick = null ;
}
2026-03-26 22:50:25 +01:00
},
_updateTunnelUrlDisplay ( url ) {
this . _updateTunnelUrlRow ( 'tunnelUrlRow' , 'tunnelUrlDisplay' , url );
this . _updateTunnelUrlRow ( 'tunnelUploadUrlRow' , 'tunnelUploadUrlDisplay' , url , '/upload.html' );
2026-03-14 21:24:22 +01:00
},
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 ();
})
2026-06-10 01:30:43 +02:00
. then ( env => {
const data = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
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 ())
2026-06-10 01:30:43 +02:00
. then ( env => {
const status = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
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 )
2026-06-10 01:30:43 +02:00
. then ( env => {
const data = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
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 ;
2026-06-10 12:25:26 -04:00
const res = await fetch ( '/api/settings' , {
2026-03-14 21:24:22 +01:00
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ tunnelEnabled : newEnabled }),
});
2026-06-10 12:25:26 -04:00
// 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 ;
}
2026-03-14 21:24:22 +01:00
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' );
2026-06-10 01:30:43 +02:00
const env = await res . json ();
const status = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
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 (); })
2026-06-10 01:30:43 +02:00
. then ( env => { const data = env ? . success === true ? env . data : env ; if ( data . svg ) qrInner . innerHTML = data . svg ; })
2026-03-14 21:24:22 +01:00
. 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' );
2026-06-10 01:30:43 +02:00
const env = await res . json ();
const info = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
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 ) } · ${ escapeHtml ( s . ip ) } · ${ 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` ;
},
2026-06-10 12:25:26 -04:00
/**
* 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 ;
},
2026-03-14 21:24:22 +01:00
async _tunnelPanelToggle ( enable ) {
try {
2026-06-10 12:25:26 -04:00
const res = await fetch ( '/api/settings' , {
2026-03-14 21:24:22 +01:00
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ tunnelEnabled : enable }),
});
2026-06-10 12:25:26 -04:00
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if ( enable && ( await this . _handleTunnelEnableRefusal ( res ))) {
this . closeTunnelPanel ();
return ;
}
2026-03-14 21:24:22 +01:00
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' );
2026-06-10 01:30:43 +02:00
const env = await res . json ();
const info = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
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 } ` );
2026-06-10 01:30:43 +02:00
const env = await res . json ();
const data = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
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 () {
2026-06-08 06:01:15 +02:00
// 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.
2026-06-14 07:33:52 +02:00
const _prev = this . loadAppSettingsFromStorage ();
const _prevGestureEnabled = ( _prev . gestureControlEnabled ?? false ) === true ;
2026-07-12 12:42:36 +02:00
// 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 ;
2026-03-14 21:24:22 +01:00
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 ,
2026-06-10 12:10:31 +02:00
showResponseViewer : document . getElementById ( 'appSettingsShowResponseViewer' ). checked ,
2026-06-14 21:21:06 +02:00
showAttachmentsButton : document . getElementById ( 'appSettingsShowAttachmentsButton' ). checked ,
2026-03-14 21:24:22 +01:00
showMonitor : document . getElementById ( 'appSettingsShowMonitor' ). checked ,
showProjectInsights : document . getElementById ( 'appSettingsShowProjectInsights' ). checked ,
showFileBrowser : document . getElementById ( 'appSettingsShowFileBrowser' ). checked ,
showSubagents : document . getElementById ( 'appSettingsShowSubagents' ). checked ,
2026-06-08 06:01:15 +02:00
showMultiMonitorButton : document . getElementById ( 'appSettingsShowMultiMonitorButton' ). checked ,
2026-06-14 05:00:28 +02:00
showPlanUsageLimits : document . getElementById ( 'appSettingsShowPlanUsageLimits' ). checked ,
2026-06-08 06:01:15 +02:00
gestureControlEnabled : document . getElementById ( 'appSettingsGestureControl' ). checked ,
2026-03-14 21:24:22 +01:00
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 ,
2026-03-24 09:22:06 +08:00
cjkInputEnabled : document . getElementById ( 'appSettingsCjkInput' ). checked ,
2026-07-01 20:41:38 -05:00
webglRendererEnabled : document . getElementById ( 'appSettingsWebglRenderer' ). checked ,
2026-04-03 04:17:33 +02:00
extendedKeyboardBar : document . getElementById ( 'appSettingsExtendedKeyboardBar' ). checked ,
2026-03-14 21:24:22 +01:00
tabTwoRows : document . getElementById ( 'appSettingsTabTwoRows' ). checked ,
2026-06-13 19:46:50 +02:00
skin : document . getElementById ( 'appSettingsSkin' ). value ,
2026-03-14 21:24:22 +01:00
// Claude CLI settings
claudeMode : document . getElementById ( 'appSettingsClaudeMode' ). value ,
allowedTools : document . getElementById ( 'appSettingsAllowedTools' ). value . trim (),
2026-06-10 09:31:58 -04:00
// Codex CLI settings
codexDangerouslyBypassApprovals : document . getElementById ( 'appSettingsCodexDangerouslyBypassApprovals' ). checked ,
2026-03-14 21:24:22 +01:00
// Claude Permissions settings
agentTeamsEnabled : document . getElementById ( 'appSettingsAgentTeams' ). checked ,
2026-06-10 16:47:05 +02:00
claudeModel : document . getElementById ( 'appSettingsClaudeModel' ). value ,
2026-03-28 16:49:55 +01:00
opusContext1mEnabled : document . getElementById ( 'appSettingsOpusContext1m' ). checked ,
2026-04-16 15:54:59 +08:00
thinkingEffort : document . getElementById ( 'appSettingsThinkingEffort' ). value ,
2026-03-14 21:24:22 +01:00
// CPU Priority settings
nice : {
enabled : document . getElementById ( 'appSettingsNiceEnabled' ). checked ,
niceValue : parseInt ( document . getElementById ( 'appSettingsNiceValue' ). value ) || 10 ,
},
};
2026-06-14 07:33:52 +02:00
// 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 ;
2026-03-14 21:24:22 +01:00
// Save to localStorage
this . saveAppSettingsToStorage ( settings );
this . _updateLocalEchoState ();
2026-07-12 12:42:36 +02:00
// 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 {}
}
2026-03-14 21:24:22 +01:00
// 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 ();
2026-06-13 19:46:50 +02:00
this . applySkin ();
2026-03-14 21:24:22 +01:00
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
2026-03-24 09:22:06 +08:00
// Apply CJK input visibility immediately
this . _updateCjkInputState ();
2026-04-03 04:17:33 +02:00
// Apply keyboard bar mode
KeyboardAccessoryBar . setMode ( settings . extendedKeyboardBar ? 'extended' : 'simple' );
2026-06-14 20:15:39 +02:00
// 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).
2026-07-12 12:42:36 +02:00
// 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.
2026-06-14 20:15:39 +02:00
// 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 ,
2026-06-14 21:21:06 +02:00
showAttachmentsButton : _ahb ,
2026-07-12 12:42:36 +02:00
webglRendererEnabled : _wgl ,
2026-06-14 20:15:39 +02:00
... serverSettings
} = settings ;
2026-03-14 21:24:22 +01:00
try {
2026-06-10 12:25:26 -04:00
const res = await this . _apiPut ( '/api/settings' , {
... serverSettings ,
2026-06-14 20:15:39 +02:00
...( settings . showPlanUsageLimits ? { statusLineTelemetry : true } : {}),
2026-06-10 12:25:26 -04:00
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 ;
}
2026-03-14 21:24:22 +01:00
// 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 ();
2026-06-08 06:01:15 +02:00
// 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 );
}
2026-03-14 21:24:22 +01:00
},
// 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 ) {
2026-03-30 16:44:00 +08:00
defaultModelEl . value = config . defaultModel || '' ;
2026-03-14 21:24:22 +01:00
}
// 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 = {
2026-03-30 16:44:00 +08:00
defaultModel : defaultModelEl ? . value || '' ,
2026-03-14 21:24:22 +01:00
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 ,
2026-06-08 06:01:15 +02:00
showMultiMonitorButton : false ,
2026-06-14 05:00:28 +02:00
showPlanUsageLimits : false ,
2026-06-14 21:21:06 +02:00
showAttachmentsButton : false ,
2026-06-08 06:01:15 +02:00
// Input
gestureControlEnabled : false ,
2026-03-14 21:24:22 +01:00
// Feature toggles - keep tracking on even on mobile
subagentTrackingEnabled : true ,
subagentActiveTabOnly : true , // Only show subagents for active tab
imageWatcherEnabled : false ,
ralphTrackerEnabled : false ,
tabTwoRows : false ,
2026-06-09 10:26:09 -04:00
cjkInputEnabled : false ,
2026-07-01 20:41:38 -05:00
webglRendererEnabled : false , // mobile always uses the DOM renderer
2026-06-13 19:46:50 +02:00
skin : 'daylight-blue' ,
2026-03-14 21:24:22 +01:00
};
}
// 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 );
}
},
2026-06-13 19:46:50 +02:00
// 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 );
},
2026-03-14 21:24:22 +01:00
applyHeaderVisibilitySettings () {
const settings = this . loadAppSettingsFromStorage ();
const defaults = this . getDefaultSettings ();
2026-06-09 10:26:09 -04:00
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 );
2026-03-14 21:24:22 +01:00
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' ;
}
2026-06-10 12:10:31 +02:00
// Hide the response viewer (eye) button when setting is disabled.
// Marker class, not inline style — the base rule is display:inline-flex !important.
2026-06-10 12:16:47 +02:00
const showResponseViewer = settings . showResponseViewer ?? defaults . showResponseViewer ?? false ;
2026-06-10 12:10:31 +02:00
const responseViewerBtn = document . querySelector ( '.btn-response-viewer-header' );
if ( responseViewerBtn ) {
responseViewerBtn . classList . toggle ( 'btn-response-viewer-header--hidden' , ! showResponseViewer );
}
2026-06-14 21:21:06 +02:00
// 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 );
}
2026-06-08 06:01:15 +02:00
// Multi-monitor button — hidden by default (App Settings → Display → "Header
// Displays"). The server renders the correct initial state on every reload;
2026-06-08 15:46:10 +02:00
// 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.
2026-06-08 06:01:15 +02:00
const showMultiMonitorButton = settings . showMultiMonitorButton ?? defaults . showMultiMonitorButton ?? false ;
const multiMonitorBtn = document . querySelector ( '.btn-multimonitor' );
if ( multiMonitorBtn ) {
2026-06-08 15:46:10 +02:00
multiMonitorBtn . classList . toggle ( 'btn-multimonitor--hidden' , ! showMultiMonitorButton );
2026-06-08 06:01:15 +02:00
}
2026-06-14 05:00:28 +02:00
// 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 );
}
2026-06-08 06:01:15 +02:00
// Notification bell is retired (notifications live in Settings → Notifications
// + the drawer); keep it hidden regardless of the notification-enabled state.
2026-03-14 21:24:22 +01:00
const notifBtn = document . querySelector ( '.btn-notifications' );
if ( notifBtn ) {
2026-06-08 06:01:15 +02:00
notifBtn . style . display = 'none' ;
2026-03-14 21:24:22 +01:00
}
// Close the drawer if notifications got disabled while it's open
2026-06-08 06:01:15 +02:00
const notifEnabled = this . notificationManager ? . preferences ? . enabled ?? true ;
2026-03-14 21:24:22 +01:00
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 ();
2026-06-10 11:12:19 +02:00
const showMonitor = settings . showMonitor ?? defaults . showMonitor ?? false ;
2026-03-14 21:24:22 +01:00
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 ? '▼' : '▲' ; // Down when open, up when collapsed
}
if ( this . subagentPanelVisible ) {
this . renderSubagentPanel ();
}
},
async loadAppSettingsFromServer ( settingsPromise = null ) {
2026-06-14 20:15:39 +02:00
// 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 */
}
2026-03-14 21:24:22 +01:00
try {
2026-06-10 01:30:43 +02:00
const settings = settingsPromise ? await settingsPromise : await fetch ( '/api/settings' ). then ( r => r . ok ? r . json () : null ). then ( env => env ? . success === true ? env . data : env );
2026-03-14 21:24:22 +01:00
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' ,
2026-06-10 12:10:31 +02:00
'showLifecycleLog' , 'showResponseViewer' ,
2026-03-14 21:24:22 +01:00
'showMonitor' , 'showProjectInsights' , 'showFileBrowser' , 'showSubagents' ,
2026-04-03 04:17:33 +02:00
'subagentActiveTabOnly' , 'tabTwoRows' , 'localEchoEnabled' , 'cjkInputEnabled' , 'extendedKeyboardBar' ,
2026-07-12 12:42:36 +02:00
'skin' , 'showPlanUsageLimits' , 'showAttachmentsButton' , 'webglRendererEnabled' ,
2026-03-14 21:24:22 +01:00
]);
2026-06-14 20:15:39 +02:00
// 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 ;
2026-03-14 21:24:22 +01:00
// 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 ) {
2026-06-10 01:30:43 +02:00
const env = await res . json ();
states = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
// 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 ) {
2026-06-10 01:30:43 +02:00
const env = await res . json ();
mapData = env ? . success === true ? env . data : env ;
2026-03-14 21:24:22 +01:00
// 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 ;
},
});