feat(settings): toggle gesture control + multi-monitor button (off by default)

Make the two experimental header features opt-in via App Settings instead of
forced on. Both default OFF.

- App Settings → Display → 'Header Displays' gains a 'Multi-monitor Button'
  toggle (setting: showMultiMonitorButton). The button is hidden in the template
  by default; the server reveals it at render when enabled, and
  applyHeaderVisibilitySettings handles live toggles from a save.
- App Settings → Display → new 'Input' section gains a 'Gesture Control' toggle
  (setting: gestureControlEnabled). The gesture overlay is injected at page
  render, so renderIndexHtml (now async) reads settings.json and injects the
  bundle only when enabled; toggling reloads the page. CODEMAN_GESTURE=1 stays
  the instance-level 'feature available' gate (CSP + assets) and exposes
  window.__codemanGestureAvailable so the Input section only shows when usable.
- The retired notification bell stays hidden regardless of notification state.

Both settings added to SettingsUpdateSchema and the mobile defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ark0N
2026-06-08 06:01:15 +02:00
co-authored by Claude Opus 4.8
parent ef01fb35b3
commit 94b26f7606
4 changed files with 102 additions and 13 deletions
+20 -1
View File
@@ -105,7 +105,7 @@
</div>
</div>
<button class="btn-icon-header btn-response-viewer-header" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-multimonitor" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<button class="btn-icon-header btn-multimonitor" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays" style="display:none;"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
@@ -969,6 +969,25 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the multi-monitor button in the header (opens Codeman spanned across all displays)">
<span class="settings-item-label">Multi-monitor Button</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowMultiMonitorButton">
<span class="slider"></span>
</label>
</div>
<!-- Input Section -->
<div id="appSettingsInputSection">
<div class="settings-section-header">Input</div>
<div class="settings-item" title="Enable the camera hand-tracking gesture overlay (applied on reload). The instance must run with CODEMAN_GESTURE=1.">
<span class="settings-item-label">Gesture Control</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsGestureControl">
<span class="slider"></span>
</label>
</div>
</div>
<!-- Tab Bar Section -->
<div class="settings-section-header">Tab Bar</div>
+40 -3
View File
@@ -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');
+3
View File
@@ -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(),
+39 -9
View File
@@ -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<string> {
let html = this.indexHtmlTemplate.replace(
'<title>Codeman</title>',
`<title>${escapeHtmlText(this.windowTitle)}</title>`
@@ -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<string, unknown> = 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('</head>', `<script>window.__CODEMAN_SOLO__=${safeId};</script>\n</head>`);
}
// 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('</head>', `<script type="module" src="/gesture/gesture-codeman.js${v}"></script>\n</head>`);
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
if (settings.gestureControlEnabled === true) {
const v = this.gestureBundleVersion();
html = html.replace(
'</head>',
`<script type="module" src="/gesture/gesture-codeman.js${v}"></script>\n</head>`
);
}
}
return html;
}