fix: prevent cross-provider model selection from silently reverting to default (#4363)

When the model dropdown rebuilds (panel switch, provider refresh, or a
live-model fetch that times out and serves a fallback catalog), a
cross-provider selection could vanish from the refreshed catalog and the
browser would snap the <select> back to its first <option> (the default
model). _reconcileModelDropdownSelection now routes each selection branch
through an _applyOrEnsure helper that delegates to
_ensureModelOptionInDropdown, re-injecting the chosen model as a custom
option when it is missing from the rebuilt catalog instead of returning
null. Branch order and per-branch model/provider arguments are unchanged.

Maintainer note: simplified the contributor's helper to delegate straight
to _ensureModelOptionInDropdown (which already applies-first-then-injects),
removing a redundant double scan; re-pointed the
test_populate_model_dropdown_reconciles_selection_after_rebuild string
assertions to the new form (the behavioral node-driver tests for boot-default
precedence and in-page selection preservation are unchanged and still pass).

Co-authored-by: Ed <edou@edoudoux.com>
This commit is contained in:
edoumo
2026-06-17 16:22:51 +00:00
committed by nesquena-hermes
parent 502f806d5b
commit 446cd2cd98
2 changed files with 27 additions and 6 deletions
+17 -3
View File
@@ -1531,14 +1531,28 @@ function _reconcileModelDropdownSelection(sel,data,previousState,opts){
// rebuild should preserve the loaded session model or the user's current
// in-page selection when it still exists in the refreshed catalog.
const shouldApplyBootDefault=!!(opts&&opts.preferProfileDefaultOnFreshBoot);
// Helper: apply the requested model, but if it is missing from the current
// catalog (cross-provider selection after a partial/timed-out rebuild), inject
// it as a custom option instead of returning null and letting the browser
// silently snap to the first <option>. _ensureModelOptionInDropdown already
// tries _applyModelToDropdown first, so delegate to it (single scan) and keep
// the plain-apply fallback for the unlikely case it is unavailable.
const _applyOrEnsure = function(modelId, providerId) {
if (typeof _ensureModelOptionInDropdown === 'function') {
return _ensureModelOptionInDropdown(modelId, sel, providerId);
}
return _applyModelToDropdown(modelId, sel, providerId);
};
if(shouldApplyBootDefault && data&&data.default_model && !(activeSession&&activeSession.model)){
return _applyModelToDropdown(data.default_model,sel,data.active_provider||null);
return _applyOrEnsure(data.default_model, data.active_provider||null);
}
if(activeSession&&activeSession.model){
return _applyModelToDropdown(activeSession.model,sel,activeSession.model_provider||null);
return _applyOrEnsure(activeSession.model, activeSession.model_provider||null);
}
if(previousState&&previousState.model){
return _applyModelToDropdown(previousState.model,sel,previousState.model_provider||null);
return _applyOrEnsure(previousState.model, previousState.model_provider||null);
}
return null;
}
+10 -3
View File
@@ -196,9 +196,16 @@ def test_populate_model_dropdown_reconciles_selection_after_rebuild():
assert "_reconcileModelDropdownSelection(sel,data,previousSelection,opts);" in UI_JS
snippet = _reconcile_selection_snippet()
assert "preferProfileDefaultOnFreshBoot" in snippet
assert "return _applyModelToDropdown(data.default_model,sel,data.active_provider||null);" in snippet
assert "return _applyModelToDropdown(activeSession.model,sel,activeSession.model_provider||null);" in snippet
assert "return _applyModelToDropdown(previousState.model,sel,previousState.model_provider||null);" in snippet
# #4363: each branch now routes through _applyOrEnsure, which delegates to
# _ensureModelOptionInDropdown so a cross-provider model missing from a
# partially-rebuilt catalog is injected as a custom option instead of the
# browser silently snapping the <select> to its first <option>. The
# branch ORDER + per-branch model/provider arguments are unchanged.
assert "_applyOrEnsure(data.default_model, data.active_provider||null)" in snippet
assert "_applyOrEnsure(activeSession.model, activeSession.model_provider||null)" in snippet
assert "_applyOrEnsure(previousState.model, previousState.model_provider||null)" in snippet
# the helper must fall back to injecting the missing option, not return null
assert "_ensureModelOptionInDropdown(modelId, sel, providerId)" in snippet
assert "_readPersistedModelState()" not in snippet
assert "localStorage.getItem('hermes-webui-model')" not in snippet