diff --git a/CHANGELOG.md b/CHANGELOG.md index 0255bc7d..9db6a5e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- Composer model picker now opens immediately from the existing option list while the dynamic `/api/models` catalog hydrates in the background, and a just-selected session model survives a hard refresh even if the refresh interrupts the async session update. Previously a slow model-catalog request could make the model button appear unresponsive, and a quick reload after selecting a model could restore the old session model. + ## [v0.51.112] — 2026-05-22 — Release CJ (stage-405 — 1-PR — session model authoritative across restore) ### Fixed diff --git a/static/boot.js b/static/boot.js index 2497df69..f2704d0a 100644 --- a/static/boot.js +++ b/static/boot.js @@ -955,6 +955,11 @@ $('modelSelect').onchange=async()=>{ if(typeof closeModelDropdown==='function') closeModelDropdown(); if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider); else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{} + if(typeof _rememberPendingSessionModel==='function') _rememberPendingSessionModel(S.session.session_id,modelState.model,modelState.model_provider); + S.session.model=modelState.model; + S.session.model_provider=modelState.model_provider||null; + if(typeof syncModelChip==='function') syncModelChip(); + syncTopbar(); // Clarify scope: composer model changes are session-local, not the global default. if(typeof showToast==='function'){ showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000); @@ -965,10 +970,12 @@ $('modelSelect').onchange=async()=>{ model:modelState.model, model_provider:modelState.model_provider||null, })}); - S.session.model=modelState.model; - S.session.model_provider=modelState.model_provider||null; - if(typeof syncModelChip==='function') syncModelChip(); - syncTopbar(); + if(typeof _readPendingSessionModel==='function'&&typeof _clearPendingSessionModel==='function'){ + const pending=_readPendingSessionModel(S.session.session_id); + if(!pending||(pending.model===modelState.model&&String(pending.model_provider||'')===String(modelState.model_provider||''))){ + _clearPendingSessionModel(S.session.session_id); + } + } _applySessionContextMetadataUpdate(data); // Warn if selected model belongs to a different provider than what Hermes is configured for if(typeof _checkProviderMismatch==='function'){ diff --git a/static/sessions.js b/static/sessions.js index d828862b..98ee6252 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -622,6 +622,7 @@ async function loadSession(sid){ if (typeof window !== 'undefined' && typeof window._resetScrollDirectionTracker === 'function') { try { window._resetScrollDirectionTracker(); } catch (_) {} } + if(typeof _applyPendingSessionModelForSession==='function') _applyPendingSessionModelForSession(sid); // Sync workspace display immediately so the chip label reflects the new session's workspace // before any async message-loading begins (mirrors how model is handled). if(typeof syncTopbar==='function') syncTopbar(); diff --git a/static/ui.js b/static/ui.js index e8bc9b8c..cc0c7435 100644 --- a/static/ui.js +++ b/static/ui.js @@ -733,6 +733,8 @@ window.addEventListener('visibilitychange',()=>{ let _dynamicModelLabels={}; window._configuredModelBadges=window._configuredModelBadges||{}; const MODEL_STATE_KEY='hermes-webui-model-state'; +const PENDING_SESSION_MODEL_PREFIX='hermes-webui-pending-session-model:'; +const PENDING_SESSION_MODEL_MAX_AGE_MS=10*60*1000; // ── Smart model resolver ──────────────────────────────────────────────────── // Finds the best matching option value in a options.""" +def test_model_picker_opens_before_async_model_catalog_finishes(): + """Opening the visible picker must not block on a slow /api/models request.""" body = _body_between(UI_JS, "async function toggleModelDropdown", "function closeModelDropdown") - assert "window._modelDropdownReady" in body assert "window._ensureModelDropdownReady" in body - assert "await" in body - assert body.index("await") < body.index("renderModelDropdown()") + render_idx = body.index("renderModelDropdown()") + open_idx = body.index("dd.classList.add('open')") + await_idx = body.find("await") + assert render_idx < open_idx + assert await_idx == -1 or open_idx < await_idx def test_populate_model_dropdown_rerenders_if_picker_is_already_open(): diff --git a/tests/test_model_selection_refresh_persistence.py b/tests/test_model_selection_refresh_persistence.py new file mode 100644 index 00000000..3f3eaad3 --- /dev/null +++ b/tests/test_model_selection_refresh_persistence.py @@ -0,0 +1,53 @@ +"""Regression coverage for model selection surviving hard refresh. + +The frontend updates the visible model chip before the async +``/api/session/update`` request returns. A hard refresh can abort that request, +so the browser must remember the session-scoped selection and reapply it on the +next ``loadSession()`` before ``syncTopbar()`` projects server metadata. +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +BOOT_JS = (ROOT / "static" / "boot.js").read_text() +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text() +UI_JS = (ROOT / "static" / "ui.js").read_text() + + +def _body_between(src: str, start: str, end: str) -> str: + start_idx = src.index(start) + end_idx = src.index(end, start_idx) + return src[start_idx:end_idx] + + +def test_model_selection_records_pending_state_before_async_session_update(): + """A refresh during /api/session/update must not lose the selected model.""" + body = _body_between(BOOT_JS, "$('modelSelect').onchange=async()=>", "$('msg').addEventListener") + + pending_idx = body.index("_rememberPendingSessionModel") + local_model_idx = body.index("S.session.model=modelState.model") + update_idx = body.index("await api('/api/session/update'") + + assert pending_idx < update_idx + assert local_model_idx < update_idx + assert "_clearPendingSessionModel" in body + + +def test_load_session_applies_pending_model_before_first_topbar_sync(): + """Reload should project the pending selection before server old metadata wins.""" + body = _body_between(SESSIONS_JS, "async function loadSession", "const activeStreamId=") + + apply_idx = body.index("_applyPendingSessionModelForSession") + sync_idx = body.index("syncTopbar()") + + assert apply_idx < sync_idx + + +def test_pending_model_helpers_are_session_scoped_and_expire(): + assert "const PENDING_SESSION_MODEL_PREFIX" in UI_JS + assert "function _pendingSessionModelKey" in UI_JS + assert "function _rememberPendingSessionModel" in UI_JS + assert "function _applyPendingSessionModelForSession" in UI_JS + assert "propagateErrors:true" in UI_JS + assert "sessionStorage" in UI_JS + assert "10*60*1000" in UI_JS