Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## [Unreleased]

## [v0.50.250] — 2026-04-30

### Fixed
- **Cross-tab thinking-card cleanup no longer touches the wrong session's DOM** — switching browser tabs while a stream is running could leave `finalizeThinkingCard()` operating on a stale `liveAssistantTurn` node — the thinking card belonged to the stream that started it, not the session currently displayed in the active tab. The guard early-returns when the live turn's `dataset.sessionId` does not match `S.session.session_id`. Per-site stamps were also added: every place that creates `liveAssistantTurn` (3 sites in `static/ui.js`) now writes the current session id onto `dataset.sessionId` so the guard has the data it needs to compare. Without the stamps the guard would always early-return (because `undefined !== "<sid>"` is always true), breaking the streaming UI completely — caught during pre-release review of #1366. Plus a regression test that fails any future `liveAssistantTurn` creation site that forgets the stamp. (`static/ui.js`, `tests/test_pr1366_finalize_thinking_card_guard.py`) @JKJameson — PR #1366
- **Clarify SSE health timer is now an actual stale-detector, not an unconditional 60s force-reconnect** — the timer at `static/messages.js:1715` shipped in v0.50.249 / PR #1355 closed and re-opened the EventSource every 60s regardless of activity, with a comment that wrongly claimed it was a "no event in 60s" detector. Effects on healthy connections: one TCP/SSE setup+teardown per minute per active session, plus a `clarify._lock` round-trip and fresh `initial` snapshot push from the server. Now tracks `lastEventAt` on `initial`/`clarify` event arrivals; only reconnects when the gap exceeds 60s. On a session with steady clarify traffic the timer never reconnects; on a long-idle session it still reconnects roughly every 60-120s (the residual idle reconnect could be eliminated with a server-side `ping` event or a longer threshold — tracked as a follow-up). Originally pulled out of the v0.50.249 batch as out-of-scope; brought back per the rule that small correctness-improving fixes ship even when flagged out-of-scope. (`static/messages.js`) — PR #1367 (Opus pre-release review of v0.50.249, SHOULD-FIX #2)
- **Preferences panel autosaves all fields (Phase 2 of #1003)** — extends the autosave pattern from the Appearance panel to the Preferences panel so 13 preference fields (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) save automatically without requiring a manual "Save Settings" click. 350ms debounce on field changes (additional 500ms wrapper on the bot_name text input). Inline status feedback (saving / saved / failed + retry). Password field still requires explicit save (security — never autosave passwords). Model selector still requires explicit save (different code path). Reuses the i18n keys (`settings_autosave_saving`/`saved`/`failed`/`retry`) already present in all 8 locales from Phase 1. (`static/index.html`, `static/panels.js`) @fecolinhares — PR #1369

## [v0.50.249] — 2026-04-30

### Added
Expand Down
1 change: 1 addition & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,7 @@ <h2 data-i18n="empty_title">What can I help with?</h2>
<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">
Expand Down
16 changes: 15 additions & 1 deletion static/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -1709,8 +1709,22 @@ function startClarifyPolling(sid) {
_startClarifyFallbackPoll(sid);
};

// Health timer: if no event received in 60s, reconnect.
// Stale-detector: track last event timestamp; only reconnect if no event
// (initial or clarify) has arrived in 60s. The server sends a keepalive
// comment line every 30s but EventSource silently consumes those; we only
// bump lastEventAt on actual application events. With no real events for
// 60s on a long-lived clarify connection the server is effectively silent
// and a reconnect is the safe move.
//
// Without the lastEventAt gate the original PR force-reconnected every 60s
// regardless of activity, which churned one TCP/SSE setup per minute per
// active session. (Opus pre-release review of v0.50.249.)
let _lastClarifyEventAt = Date.now();
const _markClarifyEvent = () => { _lastClarifyEventAt = Date.now(); };
_clarifyEventSource.addEventListener('initial', _markClarifyEvent);
_clarifyEventSource.addEventListener('clarify', _markClarifyEvent);
_clarifyHealthTimer = setInterval(function() {
if (Date.now() - _lastClarifyEventAt < 60000) return;
if (_clarifyEventSource) {
try { _clarifyEventSource.close(); } catch(_){}
_clarifyEventSource = null;
Expand Down
136 changes: 122 additions & 14 deletions static/panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2675,6 +2677,105 @@ 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');
// Only clear the global dirty flag and hide the unsaved-changes bar when
// there is no pending edit on a manually-saved field. Password and model
// are still committed via the explicit "Save Settings" button (password
// for security; model goes through /api/default-model). Without this
// guard, autosaving a checkbox right after a user typed in the password
// field would silently dismiss the password edit. (Opus pre-release
// review of v0.50.250, SHOULD-FIX Q1.)
const pwField=$('settingsPassword');
const pwDirty=!!(pwField&&pwField.value);
const modelSel=$('settingsModel');
const modelDirty=!!(modelSel&&((modelSel.value||'')!==(_settingsHermesDefaultModelOnOpen||'')));
if(!pwDirty&&!modelDirty){
_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');
Expand Down Expand Up @@ -2761,7 +2862,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){
Expand All @@ -2774,20 +2875,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);};}
Expand Down Expand Up @@ -2829,29 +2930,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});}
Expand Down
13 changes: 13 additions & 0 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -3172,6 +3172,11 @@ function renderMessages(){
seg.dataset.rawText=String(content).trim();
if(m._live){
currentAssistantTurn.id='liveAssistantTurn';
// Stamp the session id on the live turn so finalizeThinkingCard()
// and other late callbacks can verify they're operating on the
// right session's DOM (the user may have switched tabs/sessions
// while this stream is still streaming). See #1366.
if(S.session) currentAssistantTurn.dataset.sessionId=S.session.session_id;
seg.setAttribute('data-live-assistant','1');
}
if(_ERR_MSG_RE.test(String(content||'').trim())) seg.dataset.error='1';
Expand Down Expand Up @@ -3543,6 +3548,7 @@ function appendLiveToolCard(tc){
if(!turn){
turn=_createAssistantTurn();
turn.id='liveAssistantTurn';
if(S.session) turn.dataset.sessionId=S.session.session_id; // see #1366
$('msgInner').appendChild(turn);
}
const inner=_assistantTurnBlocks(turn);
Expand Down Expand Up @@ -4275,6 +4281,12 @@ function _thinkingMarkup(text=''){
: `<div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;
}
function finalizeThinkingCard(){
// Guard: only finalize thinking card if we're looking at the session that started it.
// Without this check, switching tabs while a stream is running causes finalizeThinkingCard
// to remove/modify the thinking card DOM of the wrong session — the card belongs to the
// stream that started it, not the session currently displayed.
const _guardTurn = $('liveAssistantTurn');
if(_guardTurn && S.session && _guardTurn.dataset.sessionId !== S.session.session_id) return;
if(!isSimplifiedToolCalling()){
const row=$('thinkingRow');
if(!row) return;
Expand Down Expand Up @@ -4324,6 +4336,7 @@ function appendThinking(text=''){
if(!turn){
turn=_createAssistantTurn();
turn.id='liveAssistantTurn';
if(S.session) turn.dataset.sessionId=S.session.session_id; // see #1366
$('msgInner').appendChild(turn);
}
const blocks=_assistantTurnBlocks(turn);
Expand Down
Loading
Loading