diff --git a/.changeset/settings-apply-button.md b/.changeset/settings-apply-button.md new file mode 100644 index 00000000..950394df --- /dev/null +++ b/.changeset/settings-apply-button.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +Settings has an Apply button next to Save. It saves the same way but keeps Settings open and refreshes the groups that depend on a saved value, so switching on MCP server sync (or custom model endpoints, or CLI management) brings up its controls straight away instead of needing a close-and-reopen. The MCP sync hint now says to Apply or Save first. diff --git a/src/web/public/index.html b/src/web/public/index.html index 335d3785..d28db067 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1635,6 +1635,7 @@ Save; row-reverse keeps Save to the left of it on phones. -->
+
@@ -3037,6 +3038,7 @@
+
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index a419121b..a7c2f77a 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1225,7 +1225,7 @@ Object.assign(CodemanApp.prototype, { }; // Switched on in this modal but not saved yet: the routes would only answer "disabled". if (!this._mcpSyncSavedOn) { - show('Save settings to turn MCP sync on first, then reopen Settings to preview or sync.'); + show('Apply or Save settings to turn MCP sync on first, then preview or sync.'); return; } if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) return; @@ -2506,7 +2506,25 @@ Object.assign(CodemanApp.prototype, { claudeEl.className = 'voice-provider-status' + (status?.available ? ' active' : ''); }, + /** + * Apply button: the same save as Save, but the modal stays open and the groups that depend on + * a saved value (MCP sync, custom model endpoints, CLI management) are refreshed in place, so + * a switch that unlocks more settings needs no close-and-reopen. It is a wrapper rather than + * an option on saveAppSettings() so that function's signature (which tests locate by text) + * stays as it was. + */ + async applyAppSettings() { + if (this._applyingSettings) return; + this._applyingSettings = true; + try { + await this.saveAppSettings(); + } finally { + this._applyingSettings = false; + } + }, + async saveAppSettings() { + const keepOpen = this._applyingSettings === true; // 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 _prev = this.loadAppSettingsFromStorage(); @@ -2866,7 +2884,7 @@ Object.assign(CodemanApp.prototype, { this.saveAppSettingsToStorage(settings); const cb = document.getElementById('appSettingsTunnelEnabled'); if (cb) cb.checked = false; - this.closeAppSettings(); + if (!keepOpen) this.closeAppSettings(); return; } @@ -2881,7 +2899,7 @@ Object.assign(CodemanApp.prototype, { if (webhookError) { this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning'); } else { - this.showToast('Settings saved', 'success'); + this.showToast(keepOpen ? 'Settings applied' : 'Settings saved', 'success'); } // Show tunnel-specific feedback if toggled on @@ -2895,6 +2913,8 @@ Object.assign(CodemanApp.prototype, { if (webhookError) { document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' }); + } else if (keepOpen) { + this._refreshSettingsAfterApply(settings); } else { this.closeAppSettings(); } @@ -2916,6 +2936,25 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * After Apply: bring the groups whose contents depend on a SAVED value up to date without + * reopening the modal. openAppSettings does the same on open; this is the part of it that + * a save can change, without touching what the user is editing or the scroll position. + */ + _refreshSettingsAfterApply(settings) { + // The MCP routes read the saved flag, so switching it on is only usable from now. + this._mcpSyncSavedOn = settings.mcpSyncEnabled === true; + const out = this.$('mcpSyncResult'); + if (this._mcpSyncSavedOn && out && out.textContent.startsWith('Apply or Save settings')) { + out.style.display = 'none'; + out.innerHTML = ''; + } + this.applyMcpSyncVisibility(); + this.applyCustomModelEndpointsVisibility(); + this.applyCliManagementVisibility(); + this._applyDoctorAdminGate(); + }, + // Load model configuration from server for the settings modal async loadModelConfigForSettings() { try { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index df4d47bf..b5e7ead1 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -16974,6 +16974,12 @@ html[data-tab-orientation='vertical'] .home-sessions { transform 0.1s ease; } +:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save.set-head-apply { + background: transparent; + color: var(--text); + box-shadow: inset 0 0 0 1px var(--border); +} + :is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save:hover { filter: brightness(1.08); } diff --git a/test/settings-apply-button.test.ts b/test/settings-apply-button.test.ts new file mode 100644 index 00000000..c8e989e1 --- /dev/null +++ b/test/settings-apply-button.test.ts @@ -0,0 +1,93 @@ +/** + * @fileoverview Settings "Apply": saves like Save but keeps the modal open and refreshes the + * groups that depend on a saved value (MCP sync, custom model endpoints, CLI management). + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const publicDir = resolve(import.meta.dirname, '../src/web/public'); +const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8'); +const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8'); + +function loadApp() { + const CodemanApp = function CodemanApp() {} as unknown as { prototype: Record }; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: () => null, querySelectorAll: () => [], addEventListener: () => {} }, + window: {}, + console, + }); + vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' }); + return new (CodemanApp as unknown as new () => Record)(); +} + +describe('Settings Apply button', () => { + it('sits next to Save in the footer and the phone header, wired to applyAppSettings', () => { + const start = html.indexOf('