diff --git a/src/web/public/index.html b/src/web/public/index.html index b5473817..405804e3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -105,7 +105,7 @@ - + 0 @@ -969,6 +969,25 @@ + + Multi-monitor Button + + + + + + + + + Input + + Gesture Control + + + + + + Tab Bar diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index a29a52be..ca1d8207 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -312,6 +312,13 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false; document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false; + document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false; + // Input section: gesture control is only available when the instance runs with + // CODEMAN_GESTURE=1 (server sets window.__codemanGestureAvailable). Hide the + // whole section otherwise so the toggle can't promise something that won't work. + const inputSection = document.getElementById('appSettingsInputSection'); + if (inputSection) inputSection.style.display = window.__codemanGestureAvailable ? '' : 'none'; + document.getElementById('appSettingsGestureControl').checked = settings.gestureControlEnabled ?? defaults.gestureControlEnabled ?? false; document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true; document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true; document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false; @@ -1107,6 +1114,9 @@ Object.assign(CodemanApp.prototype, { }, async saveAppSettings() { + // Gesture overlay is injected at page render (server-side), so a change to it + // only takes effect on reload — remember the prior value to decide below. + const _prevGestureEnabled = (this.loadAppSettingsFromStorage().gestureControlEnabled ?? false) === true; const settings = { defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(), defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(), @@ -1121,6 +1131,8 @@ Object.assign(CodemanApp.prototype, { showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked, showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked, showSubagents: document.getElementById('appSettingsShowSubagents').checked, + showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, + gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked, subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked, subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked, imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked, @@ -1273,6 +1285,18 @@ Object.assign(CodemanApp.prototype, { } this.closeAppSettings(); + + // The gesture overlay is injected at page render (server reads + // gestureControlEnabled from settings.json), so a change only takes effect on + // reload. Reload when it actually changed — the server PUT above already + // persisted the new value. + if (settings.gestureControlEnabled !== _prevGestureEnabled) { + this.showToast( + settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…', + 'info' + ); + setTimeout(() => location.reload(), 400); + } }, // Load model configuration from server for the settings modal @@ -1374,6 +1398,9 @@ Object.assign(CodemanApp.prototype, { showProjectInsights: false, showFileBrowser: false, showSubagents: false, + showMultiMonitorButton: false, + // Input + gestureControlEnabled: false, // Feature toggles - keep tracking on even on mobile subagentTrackingEnabled: true, subagentActiveTabOnly: true, // Only show subagents for active tab @@ -1445,13 +1472,23 @@ Object.assign(CodemanApp.prototype, { lifecycleBtn.style.display = showLifecycleLog ? '' : 'none'; } - // Hide notification bell when notifications are disabled - const notifEnabled = this.notificationManager?.preferences?.enabled ?? true; + // Multi-monitor button — hidden by default (App Settings → Display → "Header + // Displays"). The server renders the correct initial state on every reload; + // this handles a live toggle from a settings save (no reload). + const showMultiMonitorButton = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false; + const multiMonitorBtn = document.querySelector('.btn-multimonitor'); + if (multiMonitorBtn) { + multiMonitorBtn.style.display = showMultiMonitorButton ? '' : 'none'; + } + + // Notification bell is retired (notifications live in Settings → Notifications + // + the drawer); keep it hidden regardless of the notification-enabled state. const notifBtn = document.querySelector('.btn-notifications'); if (notifBtn) { - notifBtn.style.display = notifEnabled ? '' : 'none'; + notifBtn.style.display = 'none'; } // Close the drawer if notifications got disabled while it's open + const notifEnabled = this.notificationManager?.preferences?.enabled ?? true; if (!notifEnabled) { const drawer = document.getElementById('notifDrawer'); if (drawer) drawer.classList.remove('open'); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 71474319..98881c3e 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -280,6 +280,9 @@ export const SettingsUpdateSchema = z showProjectInsights: z.boolean().optional(), showFileBrowser: z.boolean().optional(), showSubagents: z.boolean().optional(), + showMultiMonitorButton: z.boolean().optional(), + // Input + gestureControlEnabled: z.boolean().optional(), // Claude CLI settings claudeMode: z.string().max(50).optional(), allowedTools: z.string().max(2000).optional(), diff --git a/src/web/server.ts b/src/web/server.ts index 22885c8b..92ddd80d 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -558,10 +558,16 @@ export class WebServer extends EventEmitter { // Security headers + CORS registerSecurityHeaders(this.app, this.https); this.app.get('/', async (_req, reply) => { - return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml()); + return reply + .header('Cache-Control', 'no-cache') + .type('text/html; charset=utf-8') + .send(await this.renderIndexHtml()); }); this.app.get('/index.html', async (_req, reply) => { - return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml()); + return reply + .header('Cache-Control', 'no-cache') + .type('text/html; charset=utf-8') + .send(await this.renderIndexHtml()); }); // Detached single-session window (undock). Serves the same SPA shell but // flags the client into "solo mode" for one session. Auth applies normally @@ -572,7 +578,10 @@ export class WebServer extends EventEmitter { // explicit route wins over the '/' static prefix. this.app.get('/session/:id', async (req, reply) => { const { id } = req.params as { id: string }; - return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml(id)); + return reply + .header('Cache-Control', 'no-cache') + .type('text/html; charset=utf-8') + .send(await this.renderIndexHtml(id)); }); // Service worker must never be cached — browsers check for SW updates on navigation this.app.get('/sw.js', async (_req, reply) => { @@ -992,7 +1001,7 @@ export class WebServer extends EventEmitter { this.broadcast(SseEvent.SessionDeleted, { id: sessionId }); } - private renderIndexHtml(soloSessionId?: string): string { + private async renderIndexHtml(soloSessionId?: string): Promise { let html = this.indexHtmlTemplate.replace( 'Codeman', `${escapeHtmlText(this.windowTitle)}` @@ -1000,6 +1009,18 @@ export class WebServer extends EventEmitter { // Cache-bust same-origin module scripts + stylesheets so a normal reload // always serves the latest (static assets carry a 1-year immutable cache). html = this.cacheBustAssets(html); + // Per-user App-Settings flags, read server-side so the page renders in the + // right initial state on every normal reload (the client apply* functions + // only run on save). Skipped for solo popups (their header differs). + const settings: Record = soloSessionId ? {} : await this.readSettings(); + // Multi-monitor header button: hidden in the template by default + // (App Settings → Display → "Header Displays"); reveal when the user enabled it. + if (settings.showMultiMonitorButton === true) { + html = html.replace( + 'aria-label="Open Codeman across all displays" style="display:none;">', + 'aria-label="Open Codeman across all displays">' + ); + } // Detached single-session ("solo") window: inject the target session id so // the client can enter solo mode even if a (network-first) service worker // later serves a cached shell. The client primarily detects solo mode from @@ -1011,12 +1032,21 @@ export class WebServer extends EventEmitter { html = html.replace('', `\n`); } // Gesture-control overlay (Phase 5): dashboard only (not solo popups, which - // have no tab strip), opt-in via CODEMAN_GESTURE=1. The bundle is served - // same-origin from /gesture/ so 'self' covers it; CSP is widened to match in - // registerSecurityHeaders under the same flag. + // have no tab strip). `CODEMAN_GESTURE=1` makes the feature *available* on + // this instance (it also widens CSP + serves the assets); the per-user + // `gestureControlEnabled` setting (App Settings → Input, default OFF) is the + // actual on/off. We expose `__codemanGestureAvailable` so the settings UI can + // show the toggle only when the feature is available, and inject the bundle + // (served same-origin from /gesture/, so 'self' covers it) only when enabled. if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') { - const v = this.gestureBundleVersion(); - html = html.replace('', `\n`); + html = html.replace('', `\n`); + if (settings.gestureControlEnabled === true) { + const v = this.gestureBundleVersion(); + html = html.replace( + '', + `\n` + ); + } } return html; }