fix: model picker correctly updates on profile switch without flicker or raw injection

Root cause: three interacting bugs caused the model picker to show the wrong
model or flicker after a profile switch.

Bug 1 — syncTopbar() fought switchToProfile().
After switchToProfile() set the picker to the profile's model, syncTopbar()
was called (via renderSessionList -> loadSession, then explicitly at the end)
and overwrote it with S.session.model -- the old session's model.
Fix: added S._pendingProfileModel flag. switchToProfile() sets it;
syncTopbar() checks it first, applies the override, then clears it.
S.session.model is also updated to the resolved value so subsequent
syncTopbar() calls are consistent.

Bug 2 — Raw option injected at top of list for mismatched model IDs.
Profile configs store model IDs like 'claude-sonnet-4-6' (hermes-agent
format: hyphens, no namespace prefix) but the dropdown has
'anthropic/claude-sonnet-4.6' (OpenRouter format: dots, with prefix).
The old code did sel.value = id, found no match, then injected a new
<option> at the top of the list -- creating a lowercase duplicate that
didn't match any real provider group entry.
Fix: _findModelInDropdown() normalises both sides (strip prefix, hyphens->dots,
lowercase) and finds the best matching existing option. No new options are ever
injected for profile switching.

Bug 3 — populateModelDropdown() injected raw option on cold load.
Same issue: if default_model from /api/models didn't exactly match a dropdown
value, an extra option was added. Fixed by using _applyModelToDropdown()
which only selects existing options.

New helpers in ui.js:
  _findModelInDropdown(modelId, sel) -- smart fuzzy match, returns matched value
  _applyModelToDropdown(modelId, sel) -- sets picker, returns resolved value

Tests: 426 passed, 0 failed.
This commit is contained in:
Nathan Esquenazi
2026-04-03 20:10:47 +00:00
parent ad755e49e5
commit c71439d8ab
2 changed files with 61 additions and 28 deletions

View File

@@ -668,17 +668,18 @@ async function switchToProfile(name) {
// Refresh model dropdown (profile may have different provider/models) // Refresh model dropdown (profile may have different provider/models)
_skillsData = null; _skillsData = null;
await populateModelDropdown(); await populateModelDropdown();
// Apply profile's default model if provided // Apply profile's default model using the smart resolver (handles id mismatches
if (data.default_model && $('modelSelect')) { // like 'claude-sonnet-4-6' vs 'anthropic/claude-sonnet-4.6' in the dropdown)
$('modelSelect').value = data.default_model; if (data.default_model) {
if ($('modelSelect').value !== data.default_model) { const sel = $('modelSelect');
// Model not in list — add it const resolved = _applyModelToDropdown(data.default_model, sel);
const opt = document.createElement('option'); const modelToUse = resolved || data.default_model;
opt.value = data.default_model; // Also update the current session's model so syncTopbar() doesn't fight us
opt.textContent = data.default_model.split('/').pop(); if (S.session) {
$('modelSelect').insertBefore(opt, $('modelSelect').firstChild); S.session.model = modelToUse;
$('modelSelect').value = data.default_model;
} }
// Store as pending so syncTopbar skips its model override on the next call
S._pendingProfileModel = modelToUse;
} }
// Refresh workspace list (now profile-local) // Refresh workspace list (now profile-local)
_workspaceList = null; _workspaceList = null;

View File

@@ -7,6 +7,38 @@ const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map // Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
let _dynamicModelLabels={}; let _dynamicModelLabels={};
// ── Smart model resolver ────────────────────────────────────────────────────
// Finds the best matching option value in a <select> for a given model ID.
// Handles mismatches like 'claude-sonnet-4-6' vs 'anthropic/claude-sonnet-4.6'.
// Returns the matched option's value (already in the list), or null if no match.
function _findModelInDropdown(modelId, sel){
if(!modelId||!sel) return null;
const opts=Array.from(sel.options).map(o=>o.value);
// 1. Exact match
if(opts.includes(modelId)) return modelId;
// 2. Normalize: lowercase, strip namespace prefix, replace hyphens→dots
const norm=s=>s.toLowerCase().replace(/^[^/]+\//,'').replace(/-/g,'.');
const target=norm(modelId);
const exact=opts.find(o=>norm(o)===target);
if(exact) return exact;
// 3. Prefix/substring: target starts with or contains a significant chunk
const base=target.replace(/\.\d+$/,''); // strip trailing version number
const partial=opts.find(o=>norm(o).startsWith(base)||norm(o).includes(base));
return partial||null;
}
// Set the model picker to the best match for modelId.
// Returns the resolved value that was actually set, or null if nothing matched.
function _applyModelToDropdown(modelId, sel){
if(!modelId||!sel) return null;
const resolved=_findModelInDropdown(modelId,sel);
if(resolved){
sel.value=resolved;
return resolved;
}
return null;
}
async function populateModelDropdown(){ async function populateModelDropdown(){
const sel=$('modelSelect'); const sel=$('modelSelect');
if(!sel) return; if(!sel) return;
@@ -30,15 +62,7 @@ async function populateModelDropdown(){
} }
// Set default model from server if no localStorage preference // Set default model from server if no localStorage preference
if(data.default_model && !localStorage.getItem('hermes-webui-model')){ if(data.default_model && !localStorage.getItem('hermes-webui-model')){
sel.value=data.default_model; _applyModelToDropdown(data.default_model, sel);
// If the default isn't in the list, add it
if(sel.value!==data.default_model){
const opt=document.createElement('option');
opt.value=data.default_model;
opt.textContent=data.default_model.split('/').pop();
sel.insertBefore(opt,sel.firstChild);
sel.value=data.default_model;
}
} }
}catch(e){ }catch(e){
// API unavailable -- keep the hardcoded HTML options as fallback // API unavailable -- keep the hardcoded HTML options as fallback
@@ -320,16 +344,24 @@ function syncTopbar(){
document.title=sessionTitle+' \u2014 Hermes'; document.title=sessionTitle+' \u2014 Hermes';
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool'); const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
$('topbarMeta').textContent=`${vis.length} messages`; $('topbarMeta').textContent=`${vis.length} messages`;
// If a profile switch just happened, apply its model rather than the session's stale value.
// S._pendingProfileModel is set by switchToProfile() and cleared here after one application.
const modelOverride=S._pendingProfileModel;
if(modelOverride){
S._pendingProfileModel=null;
_applyModelToDropdown(modelOverride,$('modelSelect'));
} else {
const m=S.session.model||''; const m=S.session.model||'';
$('modelSelect').value=m; // set dropdown first so chip reads consistent value const applied=_applyModelToDropdown(m,$('modelSelect'));
// If session model isn't in the dropdown, add it dynamically // If the model isn't in the list at all, add it so the session value is preserved
if(m && $('modelSelect').value!==m){ if(!applied && m){
const opt=document.createElement('option'); const opt=document.createElement('option');
opt.value=m; opt.value=m;
opt.textContent=getModelLabel(m); opt.textContent=getModelLabel(m);
$('modelSelect').appendChild(opt); $('modelSelect').appendChild(opt);
$('modelSelect').value=m; $('modelSelect').value=m;
} }
}
// Show Clear button only when session has messages // Show Clear button only when session has messages
const clearBtn=$('btnClearConv'); const clearBtn=$('btnClearConv');
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none'; if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none';