Fix composer model picker opening lag

This commit is contained in:
Frank Song
2026-05-22 19:01:49 +08:00
committed by nesquena-hermes
parent 73fe8f24c9
commit 53f294dc8d
6 changed files with 150 additions and 16 deletions
+4
View File
@@ -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
+11 -4
View File
@@ -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'){
+1
View File
@@ -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();
+74 -7
View File
@@ -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 <select> for a given model ID.
@@ -814,6 +816,71 @@ function _clearPersistedModelState(){
localStorage.removeItem('hermes-webui-model');
localStorage.removeItem(MODEL_STATE_KEY);
}
function _pendingSessionModelKey(sessionId){
return PENDING_SESSION_MODEL_PREFIX+String(sessionId||'');
}
function _rememberPendingSessionModel(sessionId, model, modelProvider){
const sid=String(sessionId||'').trim();
const value=String(model||'').trim();
if(!sid||!value) return;
const provider=modelProvider?String(modelProvider).trim():(_providerFromModelValue(value)||null);
try{
sessionStorage.setItem(_pendingSessionModelKey(sid), JSON.stringify({
model:value,
model_provider:provider||null,
saved_at:Date.now(),
}));
}catch(_){}
}
function _readPendingSessionModel(sessionId){
const sid=String(sessionId||'').trim();
if(!sid) return null;
try{
const raw=sessionStorage.getItem(_pendingSessionModelKey(sid));
if(!raw) return null;
const parsed=JSON.parse(raw);
const model=String(parsed&&parsed.model||'').trim();
if(!model){
sessionStorage.removeItem(_pendingSessionModelKey(sid));
return null;
}
const savedAt=Number(parsed.saved_at||0);
if(savedAt&&Date.now()-savedAt>PENDING_SESSION_MODEL_MAX_AGE_MS){
sessionStorage.removeItem(_pendingSessionModelKey(sid));
return null;
}
return {
model,
model_provider:parsed&&parsed.model_provider?String(parsed.model_provider):(_providerFromModelValue(model)||null),
};
}catch(_){
try{sessionStorage.removeItem(_pendingSessionModelKey(sid));}catch(__){}
return null;
}
}
function _clearPendingSessionModel(sessionId){
const sid=String(sessionId||'').trim();
if(!sid) return;
try{sessionStorage.removeItem(_pendingSessionModelKey(sid));}catch(_){}
}
function _applyPendingSessionModelForSession(sessionId){
if(!S.session||S.session.session_id!==sessionId) return false;
const pending=_readPendingSessionModel(sessionId);
if(!pending) return false;
const sameModel=String(S.session.model||'')===pending.model;
const sameProvider=String(S.session.model_provider||'')===String(pending.model_provider||'');
if(sameModel&&sameProvider){
_clearPendingSessionModel(sessionId);
return false;
}
S.session.model=pending.model;
S.session.model_provider=pending.model_provider||null;
const retry=_persistSessionModelCorrection(pending.model,pending.model_provider||null,{propagateErrors:true});
if(retry&&typeof retry.then==='function'){
retry.then(()=>_clearPendingSessionModel(sessionId)).catch(()=>{});
}
return true;
}
function _findModelInDropdown(modelId, sel, preferredProviderId){
if(!modelId||!sel) return null;
const options=Array.from(sel.options);
@@ -893,13 +960,14 @@ function _modelStateFromAppliedDropdown(sel, modelValue){
: {model:modelValue,model_provider:null};
return {model:state.model||modelValue,model_provider:state.model_provider||null};
}
function _persistSessionModelCorrection(model, provider){
function _persistSessionModelCorrection(model, provider, opts){
if(!S.session) return;
fetch(new URL('api/session/update',document.baseURI||location.href).href,{
const request=fetch(new URL('api/session/update',document.baseURI||location.href).href,{
method:'POST',credentials:'include',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:model,model_provider:provider||null})
}).catch(()=>{});
});
return opts&&opts.propagateErrors ? request : request.catch(()=>{});
}
function _applySessionModelFallback(sel){
if(!sel) return null;
@@ -1496,10 +1564,9 @@ async function toggleModelDropdown(){
if(typeof closeWsDropdown==='function') closeWsDropdown();
if(typeof closeReasoningDropdown==='function') closeReasoningDropdown();
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
if(typeof window._ensureModelDropdownReady==='function') window._ensureModelDropdownReady();
const ready=window._modelDropdownReady;
if(ready&&typeof ready.then==='function'){
try{await ready;}catch(_){}
if(typeof window._ensureModelDropdownReady==='function'){
const ready=window._ensureModelDropdownReady();
if(ready&&typeof ready.catch==='function') ready.catch(()=>{});
}
if(dd.classList.contains('open')) return;
renderModelDropdown();
+7 -5
View File
@@ -12,14 +12,16 @@ def _body_between(src: str, start: str, end: str) -> str:
return src[start_idx:end_idx]
def test_model_picker_open_waits_for_async_model_catalog_before_rendering():
"""Opening the visible picker must not render stale static <select> 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():
@@ -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