diff --git a/CLAUDE.md b/CLAUDE.md index 7a717a48..3674ce65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,9 +159,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Z-index layers**: subagent windows (1000), plan agents (1100), log viewers (2000), image popups (3000), local echo overlay (7). -**Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button is `display:none` in the template; `renderIndexHtml` reveals it at render when the setting is on, and `applyHeaderVisibilitySettings()` toggles it live on save. +**Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`. -**Gesture control** (the camera hand-tracking overlay) is **opt-in, default OFF**, under App Settings → Display → **Input** (`gestureControlEnabled`). `CODEMAN_GESTURE=1` makes the feature *available* on the instance (CSP widening + `/gesture/` assets) and sets `window.__codemanGestureAvailable` (the Input section only shows when set); the overlay bundle is injected by `renderIndexHtml` **only when the setting is enabled**, so that method is `async` and reads `settings.json` (`readSettings()`). Toggling the setting reloads the page (the bundle is render-injected). +**Gesture control** (the camera hand-tracking overlay) is **opt-in, default OFF**, under App Settings → Display → **Input** (`gestureControlEnabled`). `CODEMAN_GESTURE=1` makes the feature *available* on the instance (CSP widening + `/gesture/` assets) and sets `window.__codemanGestureAvailable` (the Input section only shows when set); the overlay bundle is injected by `renderIndexHtml` **only when the setting is enabled**, so that method is `async` and reads `settings.json` via `readSettings(true)` — the `true` forces a **fresh** read (bypassing the 2s `_settingsCache`), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected). **Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min). diff --git a/src/web/public/index.html b/src/web/public/index.html index 405804e3..bbf71f9d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -105,7 +105,7 @@ - + ', + '', +].join('\n'); + +function makeServer(settings: Record = {}) { + const server = new WebServer(0, false, true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (server as any).indexHtmlTemplate = TEMPLATE; + const readSettings = vi.fn(async () => settings); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (server as any).readSettings = readSettings; + return { server, readSettings }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const render = (server: WebServer, solo?: string): Promise => (server as any).renderIndexHtml(solo); + +const ORIG_GESTURE = process.env.CODEMAN_GESTURE; +afterEach(() => { + if (ORIG_GESTURE === undefined) delete process.env.CODEMAN_GESTURE; + else process.env.CODEMAN_GESTURE = ORIG_GESTURE; +}); + +describe('WebServer.renderIndexHtml', () => { + it('keeps the multi-monitor button hidden by default and reads settings FRESH', async () => { + const { server, readSettings } = makeServer({}); + const html = await render(server); + expect(html).toContain('btn-multimonitor--hidden'); + // forceFresh=true — fixes the post-save reload race against the 2s cache. + expect(readSettings).toHaveBeenCalledWith(true); + }); + + it('reveals the multi-monitor button when showMultiMonitorButton is set', async () => { + const { server } = makeServer({ showMultiMonitorButton: true }); + const html = await render(server); + expect(html).not.toContain('btn-multimonitor--hidden'); + expect(html).toContain('btn-multimonitor"'); // class list still present, only the marker stripped + }); + + it('injects the solo global and skips settings for a /session/:id window', async () => { + const { server, readSettings } = makeServer({ showMultiMonitorButton: true }); + const html = await render(server, 'sess-123'); + expect(html).toContain('window.__CODEMAN_SOLO__="sess-123"'); + expect(readSettings).not.toHaveBeenCalled(); + // Solo skips settings, so the button is NOT revealed even though the setting is on. + expect(html).toContain('btn-multimonitor--hidden'); + }); + + it('escapes the solo id so it cannot break out of the inline '); + expect(html).not.toContain(''); + expect(html).toContain('\\u003c'); + }); + + it('exposes gesture availability but injects the bundle only when enabled', async () => { + process.env.CODEMAN_GESTURE = '1'; + let { server } = makeServer({ gestureControlEnabled: false }); + let html = await render(server); + expect(html).toContain('window.__codemanGestureAvailable=true'); + expect(html).not.toContain('gesture-codeman.js'); + + ({ server } = makeServer({ gestureControlEnabled: true })); + html = await render(server); + expect(html).toContain('window.__codemanGestureAvailable=true'); + expect(html).toContain('gesture-codeman.js'); + }); + + it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => { + delete process.env.CODEMAN_GESTURE; + const { server } = makeServer({ gestureControlEnabled: true }); + const html = await render(server); + expect(html).not.toContain('__codemanGestureAvailable'); + expect(html).not.toContain('gesture-codeman.js'); + }); +});