fix: autosave preferences settings (#1369, fixes #1003 phase 2)

Phase 2 of #1003: extend the autosave pattern from the Appearance
panel to the Preferences panel so all preference changes are saved
automatically without requiring a manual 'Save Settings' click.

Mirrors the Phase 1 (Appearance) pattern exactly:
- 350ms debounce on field changes (500ms additional debounce on
  the bot_name text input — effective ~850ms latency for typing)
- Inline status feedback (saving / saved / failed + retry button)
- Clears dirty flag and hides unsaved-changes bar after successful save
- Password field excluded — still requires explicit save (security)
- Model selector excluded — still requires explicit save

13 fields now autosaving: send_key, language, show_token_usage,
simplified_tool_calling, show_cli_sessions, sync_to_insights,
check_for_updates, sound_enabled, notifications_enabled,
sidebar_density, auto_title_refresh_every, busy_input_mode, bot_name.

i18n keys (settings_autosave_saving/saved/failed/retry) already exist
in all 8 locales from Phase 1.

Co-authored-by: Feco Linhares <feco.linhares@gmail.com>
This commit is contained in:
Feco Linhares
2026-04-30 22:38:44 +00:00
committed by nesquena-hermes
parent 07350426f2
commit 645dfa25af
2 changed files with 110 additions and 14 deletions
+1
View File
@@ -793,6 +793,7 @@
<input type="text" id="settingsBotName" placeholder="Hermes" maxlength="64" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
<div id="settingsPreferencesAutosaveStatus" class="settings-autosave-status" aria-live="polite"></div>
</div>
<div class="settings-pane" id="settingsPaneProviders">
<div class="settings-section-head">
+109 -14
View File
@@ -2492,6 +2492,8 @@ let _settingsSection = 'conversation';
let _currentSettingsSection = 'conversation';
let _settingsAppearanceAutosaveTimer = null;
let _settingsAppearanceAutosaveRetryPayload = null;
let _settingsPreferencesAutosaveTimer = null;
let _settingsPreferencesAutosaveRetryPayload = null;
function switchSettingsSection(name){
const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='system')?name:'conversation';
@@ -2675,6 +2677,92 @@ function _retryAppearanceAutosave(){
_autosaveAppearanceSettings(payload);
}
// ── Phase 2: Preferences autosave (Issue #1003) ───────────────────────
function _preferencesPayloadFromUi(){
const payload={};
const sendKeySel=$('settingsSendKey');
if(sendKeySel) payload.send_key=sendKeySel.value;
const langSel=$('settingsLanguage');
if(langSel) payload.language=langSel.value;
const showUsageCb=$('settingsShowTokenUsage');
if(showUsageCb) payload.show_token_usage=showUsageCb.checked;
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
if(simplifiedToolCb) payload.simplified_tool_calling=simplifiedToolCb.checked;
const showCliCb=$('settingsShowCliSessions');
if(showCliCb) payload.show_cli_sessions=showCliCb.checked;
const syncCb=$('settingsSyncInsights');
if(syncCb) payload.sync_to_insights=syncCb.checked;
const updateCb=$('settingsCheckUpdates');
if(updateCb) payload.check_for_updates=updateCb.checked;
const soundCb=$('settingsSoundEnabled');
if(soundCb) payload.sound_enabled=soundCb.checked;
const notifCb=$('settingsNotificationsEnabled');
if(notifCb) payload.notifications_enabled=notifCb.checked;
const sidebarDensitySel=$('settingsSidebarDensity');
if(sidebarDensitySel) payload.sidebar_density=sidebarDensitySel.value;
const autoTitleRefreshSel=$('settingsAutoTitleRefresh');
if(autoTitleRefreshSel) payload.auto_title_refresh_every=parseInt(autoTitleRefreshSel.value,10);
const busyInputModeSel=$('settingsBusyInputMode');
if(busyInputModeSel) payload.busy_input_mode=busyInputModeSel.value;
const botNameField=$('settingsBotName');
if(botNameField) payload.bot_name=botNameField.value;
return payload;
}
function _setPreferencesAutosaveStatus(state){
const el=$('settingsPreferencesAutosaveStatus');
if(!el) return;
el.className='settings-autosave-status';
if(!state){
el.textContent='';
return;
}
el.classList.add('is-'+state);
if(state==='saving'){
el.textContent=t('settings_autosave_saving');
}else if(state==='saved'){
el.textContent=t('settings_autosave_saved');
}else if(state==='failed'){
el.innerHTML=`<span>${esc(t('settings_autosave_failed'))}</span> <button type=\"button\" onclick=\"_retryPreferencesAutosave()\">${esc(t('settings_autosave_retry'))}</button>`;
}
}
function _rememberPreferencesSaved(payload){
if(!payload) return;
if(payload.send_key!==undefined) localStorage.setItem('hermes-pref-send_key',payload.send_key);
if(payload.language!==undefined) localStorage.setItem('hermes-pref-language',payload.language);
}
function _schedulePreferencesAutosave(){
const payload=_preferencesPayloadFromUi();
_rememberPreferencesSaved(payload);
_settingsPreferencesAutosaveRetryPayload=payload;
_setPreferencesAutosaveStatus('saving');
if(_settingsPreferencesAutosaveTimer) clearTimeout(_settingsPreferencesAutosaveTimer);
_settingsPreferencesAutosaveTimer=setTimeout(()=>_autosavePreferencesSettings(payload),350);
}
async function _autosavePreferencesSettings(payload){
try{
await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
_settingsPreferencesAutosaveRetryPayload=null;
_setPreferencesAutosaveStatus('saved');
_settingsDirty=false;
const bar=$('settingsUnsavedBar');
if(bar) bar.style.display='none';
}catch(e){
console.warn('[settings] preferences autosave failed', e);
_setPreferencesAutosaveStatus('failed');
}
}
function _retryPreferencesAutosave(){
const payload=_settingsPreferencesAutosaveRetryPayload||_preferencesPayloadFromUi();
_setPreferencesAutosaveStatus('saving');
_autosavePreferencesSettings(payload);
}
async function loadSettingsPanel(){
try{
const settings=await api('/api/settings');
@@ -2761,7 +2849,7 @@ async function loadSettingsPanel(){
}
// Send key preference
const sendKeySel=$('settingsSendKey');
if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_markSettingsDirty,{once:false});}
if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
// Language preference — populate from LOCALES bundle
const langSel=$('settingsLanguage');
if(langSel){
@@ -2774,20 +2862,20 @@ async function loadSettingsPanel(){
}
}
langSel.value=resolvedLanguage;
langSel.addEventListener('change',_markSettingsDirty,{once:false});
langSel.addEventListener('change',_schedulePreferencesAutosave,{once:false});
}
const showUsageCb=$('settingsShowTokenUsage');
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_markSettingsDirty,{once:false});}
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
if(simplifiedToolCb){simplifiedToolCb.checked=settings.simplified_tool_calling!==false;simplifiedToolCb.addEventListener('change',_markSettingsDirty,{once:false});}
if(simplifiedToolCb){simplifiedToolCb.checked=settings.simplified_tool_calling!==false;simplifiedToolCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const showCliCb=$('settingsShowCliSessions');
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});}
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const syncCb=$('settingsSyncInsights');
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});}
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const updateCb=$('settingsCheckUpdates');
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_markSettingsDirty,{once:false});}
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const soundCb=$('settingsSoundEnabled');
if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_markSettingsDirty,{once:false});}
if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
// TTS settings (localStorage-only, no server round-trip needed)
const ttsEnabledCb=$('settingsTtsEnabled');
if(ttsEnabledCb){ttsEnabledCb.checked=localStorage.getItem('hermes-tts-enabled')==='true';ttsEnabledCb.onchange=function(){localStorage.setItem('hermes-tts-enabled',this.checked?'true':'false');_applyTtsEnabled(this.checked);};}
@@ -2829,29 +2917,36 @@ async function loadSettingsPanel(){
ttsPitchSlider.oninput=function(){if(ttsPitchValue)ttsPitchValue.textContent=parseFloat(this.value).toFixed(1);localStorage.setItem('hermes-tts-pitch',this.value);};
}
const notifCb=$('settingsNotificationsEnabled');
if(notifCb){notifCb.checked=!!settings.notifications_enabled;notifCb.addEventListener('change',_markSettingsDirty,{once:false});}
if(notifCb){notifCb.checked=!!settings.notifications_enabled;notifCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
// show_thinking has no settings panel checkbox — controlled via /reasoning show|hide
const sidebarDensitySel=$('settingsSidebarDensity');
if(sidebarDensitySel){
sidebarDensitySel.value=settings.sidebar_density==='detailed'?'detailed':'compact';
sidebarDensitySel.addEventListener('change',_markSettingsDirty,{once:false});
sidebarDensitySel.addEventListener('change',_schedulePreferencesAutosave,{once:false});
}
const autoTitleRefreshSel=$('settingsAutoTitleRefresh');
if(autoTitleRefreshSel){
const val=String(settings.auto_title_refresh_every||'0');
autoTitleRefreshSel.value=['0','5','10','20'].includes(val)?val:'0';
autoTitleRefreshSel.addEventListener('change',_markSettingsDirty,{once:false});
autoTitleRefreshSel.addEventListener('change',_schedulePreferencesAutosave,{once:false});
}
// Busy input mode
const busyInputModeSel=$('settingsBusyInputMode');
if(busyInputModeSel){
const val=String(settings.busy_input_mode||'queue');
busyInputModeSel.value=['queue','interrupt','steer'].includes(val)?val:'queue';
busyInputModeSel.addEventListener('change',_markSettingsDirty,{once:false});
busyInputModeSel.addEventListener('change',_schedulePreferencesAutosave,{once:false});
}
// Bot name
// Bot name — debounced autosave (text input)
const botNameField=$('settingsBotName');
if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});}
if(botNameField){
botNameField.value=settings.bot_name||'Hermes';
let botNameTimer=null;
botNameField.addEventListener('input',()=>{
if(botNameTimer) clearTimeout(botNameTimer);
botNameTimer=setTimeout(_schedulePreferencesAutosave,500);
},{once:false});
}
// Password field: always blank (we don't send hash back)
const pwField=$('settingsPassword');
if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});}