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