From bf36eb0db40a9b14e0ee4bc5f2689704e19222e6 Mon Sep 17 00:00:00 2001 From: Kevin Crawley Date: Wed, 1 Jul 2026 20:41:38 -0500 Subject: [PATCH 1/2] feat(terminal): add WebGL renderer toggle in settings Adds a 'WebGL Renderer' toggle to Settings > Appearance (desktop). WebGL stays on by default; users can turn it off to force the DOM renderer when they hit GPU glitches, without needing the ?nowebgl URL param. Explicit opt-in (or ?webgl=force) clears a stale auto-fallback marker. Mobile skip and the long-task auto-fallback safety net are unchanged. The device/param/sticky/pref interaction is factored into a pure, unit-tested shouldSkipWebGL() helper in constants.js. --- .changeset/webgl-renderer-toggle.md | 5 ++ src/web/public/constants.js | 26 ++++++++++ src/web/public/index.html | 7 +++ src/web/public/settings-ui.js | 7 +++ src/web/public/terminal-ui.js | 25 +++++++--- test/webgl-fallback.test.ts | 76 +++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 .changeset/webgl-renderer-toggle.md diff --git a/.changeset/webgl-renderer-toggle.md b/.changeset/webgl-renderer-toggle.md new file mode 100644 index 00000000..0e620d7a --- /dev/null +++ b/.changeset/webgl-renderer-toggle.md @@ -0,0 +1,5 @@ +--- +"aicodeman": patch +--- + +Add a **WebGL Renderer** toggle to Settings → Appearance (desktop). WebGL stays on by default; turning it off forces the DOM renderer for users who hit GPU glitches, without needing the `?nowebgl` URL param. Turning it back on (or `?webgl=force`) clears any stale auto-fallback marker. The existing mobile skip and long-task auto-fallback safety net are unchanged. The skip decision is factored into a pure, unit-tested `shouldSkipWebGL()` helper. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 379b97c3..f690d086 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -111,6 +111,31 @@ function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK return recent.length >= config.LONGTASK_COUNT; } +/** + * Pure decision for whether to skip the WebGL renderer at terminal init, and + * whether to clear the auto-fallback sticky marker. Keeps the interaction + * between device type, URL params, the sticky marker, and the user's settings + * toggle in one testable place (terminal-ui.js calls this). + * + * Precedence (desktop only — mobile always skips): + * 1. user toggle OFF -> skip (one-shot opt-out, sticky untouched) + * 2. ?nowebgl -> skip (one-shot opt-out, sticky untouched) + * 3. user toggle ON / ?webgl=force -> enable + clear stale sticky marker + * 4. untouched (default on) -> respect the auto-fallback sticky marker + * + * @param {{deviceType?: string, noWebglParam?: boolean, forceParam?: boolean, + * stickyDisabled?: boolean, userPrefEnabled?: (boolean|undefined)}} [input] + * @returns {{skip: boolean, clearSticky: boolean}} + */ +function shouldSkipWebGL(input = {}) { + if (input.deviceType !== 'desktop') return { skip: true, clearSticky: false }; + const pref = input.userPrefEnabled; // true | false | undefined (default on) + if (pref === false) return { skip: true, clearSticky: false }; + if (input.noWebglParam) return { skip: true, clearSticky: false }; + if (pref === true || input.forceParam) return { skip: false, clearSticky: true }; + return { skip: !!input.stickyDisabled, clearSticky: false }; +} + // Expose for tests. `const` declarations at the top of a non-module script // are global lexical bindings but not `window` properties, so explicit // assignment is the test-visible API surface. @@ -129,6 +154,7 @@ function shouldAutoWrapTabs(input) { if (typeof window !== 'undefined') { window.WEBGL_FALLBACK = WEBGL_FALLBACK; window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip; + window.shouldSkipWebGL = shouldSkipWebGL; window.CodemanTabOverflow = { shouldAutoWrapTabs, }; diff --git a/src/web/public/index.html b/src/web/public/index.html index 90526869..b4466322 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -939,6 +939,13 @@ +
+ WebGL Renderer + +
Input
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index a4ceb53f..26232ad2 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -309,6 +309,11 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false; document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false; document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue'; + // WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide + // the toggle there so it can't promise something that won't apply). + document.getElementById('appSettingsWebglRenderer').checked = settings.webglRendererEnabled ?? defaults.webglRendererEnabled ?? true; + const webglItem = document.getElementById('appSettingsWebglRendererItem'); + if (webglItem) webglItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none'; document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false; document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false; document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; @@ -1377,6 +1382,7 @@ Object.assign(CodemanApp.prototype, { tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked, localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked, + webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked, extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked, tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked, skin: document.getElementById('appSettingsSkin').value, @@ -1694,6 +1700,7 @@ Object.assign(CodemanApp.prototype, { ralphTrackerEnabled: false, tabTwoRows: false, cjkInputEnabled: false, + webglRendererEnabled: false, // mobile always uses the DOM renderer skin: 'daylight-blue', }; } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index f7b10bda..f9ca9c83 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -246,9 +246,6 @@ Object.assign(CodemanApp.prototype, { // Lazy-loaded: script downloaded only on desktop (saves 244KB on mobile). this._webglAddon = null; const _params = new URLSearchParams(location.search); - if (_params.get('webgl') === 'force') { - try { localStorage.removeItem('codeman-webgl-disabled'); } catch {} - } const _stickyDisabled = (() => { try { const raw = localStorage.getItem('codeman-webgl-disabled'); @@ -263,11 +260,23 @@ Object.assign(CodemanApp.prototype, { return true; } catch { return false; } })(); - const skipWebGL = - MobileDetection.getDeviceType() !== 'desktop' || - _params.has('nowebgl') || - _stickyDisabled; - if (_stickyDisabled) { + // User's "WebGL Renderer" toggle (Settings > Appearance). undefined = untouched + // (desktop default on); false = explicit opt-out; true = explicit opt-in. + const _webglSettings = this.loadAppSettingsFromStorage(); + const _webglDefaults = this.getDefaultSettings(); + const _webglPref = _webglSettings.webglRendererEnabled ?? _webglDefaults.webglRendererEnabled; + const { skip: skipWebGL, clearSticky: _clearWebglSticky } = shouldSkipWebGL({ + deviceType: MobileDetection.getDeviceType(), + noWebglParam: _params.has('nowebgl'), + forceParam: _params.get('webgl') === 'force', + stickyDisabled: _stickyDisabled, + userPrefEnabled: _webglPref, + }); + // Explicit opt-in (toggle ON) or ?webgl=force retires a stale auto-fallback marker. + if (_clearWebglSticky) { + try { localStorage.removeItem('codeman-webgl-disabled'); } catch {} + } + if (skipWebGL && _stickyDisabled) { console.log('[CRASH-DIAG] WebGL sticky-disabled from prior stalls — DOM renderer in use. Re-enable: ?webgl=force'); } if (!skipWebGL) { diff --git a/test/webgl-fallback.test.ts b/test/webgl-fallback.test.ts index e6d376d8..23b6b4cc 100644 --- a/test/webgl-fallback.test.ts +++ b/test/webgl-fallback.test.ts @@ -215,4 +215,80 @@ describe('WebGL longtask auto-fallback', () => { expect(result).toBeNull(); }); }); + + describe('shouldSkipWebGL — renderer toggle + fallback precedence', () => { + type Input = { + deviceType?: string; + noWebglParam?: boolean; + forceParam?: boolean; + stickyDisabled?: boolean; + userPrefEnabled?: boolean; + }; + const run = async (input: Input): Promise<{ skip: boolean; clearSticky: boolean }> => + page.evaluate( + (i) => + ( + window as unknown as { + shouldSkipWebGL: (x: unknown) => { skip: boolean; clearSticky: boolean }; + } + ).shouldSkipWebGL(i), + input + ); + + it('exposes shouldSkipWebGL on window', async () => { + const t = await page.evaluate( + () => typeof (window as unknown as { shouldSkipWebGL?: unknown }).shouldSkipWebGL + ); + expect(t).toBe('function'); + }); + + it('mobile always skips, even when the user opted in', async () => { + expect(await run({ deviceType: 'mobile', userPrefEnabled: true })).toEqual({ + skip: true, + clearSticky: false, + }); + }); + + it('explicit opt-out skips without touching the sticky marker', async () => { + expect(await run({ deviceType: 'desktop', userPrefEnabled: false, stickyDisabled: false })).toEqual({ + skip: true, + clearSticky: false, + }); + }); + + it('?nowebgl is a one-shot opt-out (sticky untouched)', async () => { + expect(await run({ deviceType: 'desktop', noWebglParam: true })).toEqual({ + skip: true, + clearSticky: false, + }); + }); + + it('explicit opt-in enables and clears a stale sticky marker', async () => { + expect(await run({ deviceType: 'desktop', userPrefEnabled: true, stickyDisabled: true })).toEqual({ + skip: false, + clearSticky: true, + }); + }); + + it('?webgl=force enables and clears the sticky marker', async () => { + expect(await run({ deviceType: 'desktop', forceParam: true, stickyDisabled: true })).toEqual({ + skip: false, + clearSticky: true, + }); + }); + + it('untouched default respects an active sticky marker', async () => { + expect(await run({ deviceType: 'desktop', stickyDisabled: true })).toEqual({ + skip: true, + clearSticky: false, + }); + }); + + it('untouched default enables WebGL when nothing is disabling it', async () => { + expect(await run({ deviceType: 'desktop', stickyDisabled: false })).toEqual({ + skip: false, + clearSticky: false, + }); + }); + }); }); From 7fa52cdcd6e381ee3f502dbabc166349edaa6847 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 12 Jul 2026 12:42:36 +0200 Subject: [PATCH 2/2] fix(review): make WebGL toggle per-device and fix sticky-marker semantics (PR #140) - saveAppSettings no longer sends webglRendererEnabled on the settings PUT: the key is absent from the .strict() SettingsUpdateSchema, so every save 400'd with INVALID_INPUT, silently killing all server-side settings persistence. Stripped in the per-device destructure alongside localEchoEnabled/skin/etc. - shouldSkipWebGL now treats a stored true like the untouched default w.r.t. the sticky marker: the checkbox defaults checked on desktop, so any unrelated save stored true and every page load then cleared the 'codeman-webgl-disabled' marker, permanently defeating the GPU-stall auto-fallback. Only ?webgl=force clears the marker at init. - The marker is instead retired on a real OFF->ON toggle flip detected at save time (mirrors the _prevGestureEnabled pattern in settings-ui.js). - webglRendererEnabled added to the displayKeys per-device set in loadAppSettingsFromServer (renderer choice is device/GPU-specific; syncing would leak mobile's hidden-checkbox false onto desktop). - Tests: stored true + sticky marker -> still skips WebGL; OFF->ON save clears the marker and keeps the key off the wire; default-checked save leaves the marker alone; ?webgl=force / ?nowebgl behavior unchanged. Co-Authored-By: Claude Fable 5 --- src/web/public/constants.js | 15 ++++--- src/web/public/settings-ui.js | 19 +++++++- src/web/public/terminal-ui.js | 4 +- test/webgl-fallback.test.ts | 83 ++++++++++++++++++++++++++++++++--- 4 files changed, 109 insertions(+), 12 deletions(-) diff --git a/src/web/public/constants.js b/src/web/public/constants.js index f690d086..a3cdaa93 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -120,8 +120,14 @@ function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK * Precedence (desktop only — mobile always skips): * 1. user toggle OFF -> skip (one-shot opt-out, sticky untouched) * 2. ?nowebgl -> skip (one-shot opt-out, sticky untouched) - * 3. user toggle ON / ?webgl=force -> enable + clear stale sticky marker - * 4. untouched (default on) -> respect the auto-fallback sticky marker + * 3. ?webgl=force -> enable + clear stale sticky marker + * 4. toggle ON / untouched -> respect the auto-fallback sticky marker + * + * A stored `true` is treated like the untouched default here: the checkbox + * ships checked on desktop, so any unrelated settings save stores `true` — + * letting it clear the marker would permanently defeat the GPU-stall + * auto-fallback safety net. The marker is only retired by ?webgl=force or by + * a real OFF->ON toggle flip, which saveAppSettings() detects at save time. * * @param {{deviceType?: string, noWebglParam?: boolean, forceParam?: boolean, * stickyDisabled?: boolean, userPrefEnabled?: (boolean|undefined)}} [input] @@ -129,10 +135,9 @@ function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK */ function shouldSkipWebGL(input = {}) { if (input.deviceType !== 'desktop') return { skip: true, clearSticky: false }; - const pref = input.userPrefEnabled; // true | false | undefined (default on) - if (pref === false) return { skip: true, clearSticky: false }; + if (input.userPrefEnabled === false) return { skip: true, clearSticky: false }; if (input.noWebglParam) return { skip: true, clearSticky: false }; - if (pref === true || input.forceParam) return { skip: false, clearSticky: true }; + if (input.forceParam) return { skip: false, clearSticky: true }; return { skip: !!input.stickyDisabled, clearSticky: false }; } diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 26232ad2..a0e3b214 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1359,6 +1359,9 @@ Object.assign(CodemanApp.prototype, { // only takes effect on reload — remember the prior value to decide below. const _prev = this.loadAppSettingsFromStorage(); const _prevGestureEnabled = (_prev.gestureControlEnabled ?? false) === true; + // WebGL toggle: default ON (desktop), so only an explicit stored false counts + // as "previously off" — used below to detect a real OFF→ON flip. + const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true; const settings = { defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(), defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(), @@ -1417,6 +1420,15 @@ Object.assign(CodemanApp.prototype, { this.saveAppSettingsToStorage(settings); this._updateLocalEchoState(); + // A real OFF→ON flip of the WebGL toggle retires the GPU-stall auto-fallback + // marker so the next reload actually re-tries WebGL. Only the transition + // clears it — an incidental save with the checkbox default-checked must NOT + // defeat the sticky safety net (shouldSkipWebGL treats stored true like the + // untouched default at page load). + if (!_prevWebglEnabled && settings.webglRendererEnabled) { + try { localStorage.removeItem('codeman-webgl-disabled'); } catch {} + } + // Save voice settings to localStorage + include in server payload for cross-device sync const voiceSettings = { apiKey: document.getElementById('voiceDeepgramKey').value.trim(), @@ -1527,6 +1539,10 @@ Object.assign(CodemanApp.prototype, { // Strip device-specific DISPLAY keys so they never sync across devices — // localEcho/cjk/extendedKeyboard/skin are per-platform, and showPlanUsageLimits // is per-device too (desktop can show the usage chip while mobile stays hidden). + // webglRendererEnabled is per-device as well (renderer choice is GPU-specific, + // and syncing would leak mobile's hidden-checkbox false onto desktop); it's + // also absent from SettingsUpdateSchema, which is .strict() — sending it + // would 400 the whole settings PUT. // Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on // ENABLE only, so a device with the chip OFF never strips the exporter that // another device's chip depends on — see system-routes settings handler). @@ -1537,6 +1553,7 @@ Object.assign(CodemanApp.prototype, { skin: _skin, showPlanUsageLimits: _pul, showAttachmentsButton: _ahb, + webglRendererEnabled: _wgl, ...serverSettings } = settings; try { @@ -2037,7 +2054,7 @@ Object.assign(CodemanApp.prototype, { 'showLifecycleLog', 'showResponseViewer', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', - 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', + 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'webglRendererEnabled', ]); // The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop // can show it while mobile stays hidden. It used to sync, so an older diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index f9ca9c83..e573b8ad 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -272,7 +272,9 @@ Object.assign(CodemanApp.prototype, { stickyDisabled: _stickyDisabled, userPrefEnabled: _webglPref, }); - // Explicit opt-in (toggle ON) or ?webgl=force retires a stale auto-fallback marker. + // Only ?webgl=force retires the auto-fallback marker at init — a stored + // toggle ON is incidental (checkbox defaults checked) and must not defeat + // the sticky safety net. An OFF→ON flip clears it in saveAppSettings(). if (_clearWebglSticky) { try { localStorage.removeItem('codeman-webgl-disabled'); } catch {} } diff --git a/test/webgl-fallback.test.ts b/test/webgl-fallback.test.ts index 23b6b4cc..1a8b6514 100644 --- a/test/webgl-fallback.test.ts +++ b/test/webgl-fallback.test.ts @@ -236,9 +236,7 @@ describe('WebGL longtask auto-fallback', () => { ); it('exposes shouldSkipWebGL on window', async () => { - const t = await page.evaluate( - () => typeof (window as unknown as { shouldSkipWebGL?: unknown }).shouldSkipWebGL - ); + const t = await page.evaluate(() => typeof (window as unknown as { shouldSkipWebGL?: unknown }).shouldSkipWebGL); expect(t).toBe('function'); }); @@ -263,10 +261,19 @@ describe('WebGL longtask auto-fallback', () => { }); }); - it('explicit opt-in enables and clears a stale sticky marker', async () => { + it('stored toggle ON still respects the sticky marker (incidental saves must not defeat auto-fallback)', async () => { + // The checkbox defaults checked on desktop, so any unrelated settings save + // stores true — that must NOT act like ?webgl=force and clear the marker. expect(await run({ deviceType: 'desktop', userPrefEnabled: true, stickyDisabled: true })).toEqual({ + skip: true, + clearSticky: false, + }); + }); + + it('stored toggle ON enables WebGL when no sticky marker is set', async () => { + expect(await run({ deviceType: 'desktop', userPrefEnabled: true, stickyDisabled: false })).toEqual({ skip: false, - clearSticky: true, + clearSticky: false, }); }); @@ -291,4 +298,70 @@ describe('WebGL longtask auto-fallback', () => { }); }); }); + + describe('saveAppSettings — per-device WebGL toggle semantics', () => { + type SaveHarnessApp = { + loadAppSettingsFromStorage: () => Record; + saveAppSettingsToStorage: (s: Record) => void; + saveAppSettings: () => Promise; + _apiPut: (path: string, body: Record) => Promise; + saveModelConfigFromSettings: () => Promise; + _syncPushPreferences: () => void; + }; + + /** + * Drive the real saveAppSettings() in the page with the settings PUT stubbed + * out. Seeds the stored blob (via saveAppSettingsToStorage so the in-memory + * cache stays coherent), sets the sticky marker + checkbox, saves, and + * reports what happened to the marker and the captured server payload. + */ + const runSave = async (opts: { storedPref: boolean | undefined }) => + page.evaluate(async (o) => { + const app = (window as unknown as { app: SaveHarnessApp }).app; + const prevStored = { ...app.loadAppSettingsFromStorage() }; + const origPut = app._apiPut; + const origModel = app.saveModelConfigFromSettings; + const origSync = app._syncPushPreferences; + let captured: Record | null = null; + try { + const seeded = { ...prevStored }; + if (o.storedPref === undefined) delete seeded.webglRendererEnabled; + else seeded.webglRendererEnabled = o.storedPref; + app.saveAppSettingsToStorage(seeded); + localStorage.setItem('codeman-webgl-disabled', JSON.stringify({ at: Date.now() })); + (document.getElementById('appSettingsWebglRenderer') as HTMLInputElement).checked = true; + app._apiPut = async (_path, body) => { + captured = body; + return { ok: true }; + }; + app.saveModelConfigFromSettings = async () => {}; + app._syncPushPreferences = () => {}; + await app.saveAppSettings(); + return { + markerCleared: localStorage.getItem('codeman-webgl-disabled') === null, + payloadHasWebglKey: captured ? 'webglRendererEnabled' in captured : null, + }; + } finally { + app._apiPut = origPut; + app.saveModelConfigFromSettings = origModel; + app._syncPushPreferences = origSync; + localStorage.removeItem('codeman-webgl-disabled'); + app.saveAppSettingsToStorage(prevStored); + } + }, opts); + + it('a real OFF→ON flip clears the sticky marker and keeps the key off the settings PUT', async () => { + const result = await runSave({ storedPref: false }); + expect(result.markerCleared).toBe(true); + expect(result.payloadHasWebglKey).toBe(false); + }); + + it('a save with the toggle merely default-checked leaves the sticky marker alone', async () => { + // No stored OFF → this is the "unrelated settings save stores true" case; + // clearing here would permanently defeat the GPU-stall auto-fallback. + const result = await runSave({ storedPref: undefined }); + expect(result.markerCleared).toBe(false); + expect(result.payloadHasWebglKey).toBe(false); + }); + }); });