stage #4537: keep open model picker stable during stream sync (#4531, head 92de7615 incl duplicate-rebuild fix) + v0.51.535 CHANGELOG

This commit is contained in:
nesquena-hermes committed 2026-06-20 06:10:04 +00:00
1 parent 9988f0a3f6
commit 2b398bb2b1
3 files changed
+196 -14

No files matched your search

+6
View File
@@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.535] — 2026-06-20 — Release ST (open model picker stays stable during stream sync)
### Fixed
- **The open model picker no longer jumps/rebuilds during streaming when the model hasn't changed (#4531).** Streamed updates reapply the session model through both the start-data path and `syncTopbar()`, which rebuilt the open picker every time even though nothing changed — losing your scroll position in the list. `_applyModelToDropdown` now rebuilds the open picker only when the resolved model or provider actually differs from the current selection, and the live-models-arrived path avoids a duplicate rebuild when the catalog-growth refresh already re-applied the session model (the force-refresh on catalog growth still preserves #1169 — the session model wins over a premature fallback). Thanks @rodboev.
## [v0.51.534] — 2026-06-20 — Release SS (clarify prompt no longer bricks the session on expiry)
### Fixed
+21 -5
View File
@@ -1811,14 +1811,23 @@ function _refreshOpenModelDropdown(){
if(typeof _positionModelDropdown==='function') _positionModelDropdown();
}
}
function _applyModelToDropdown(modelId, sel, preferredProviderId){
function _applyModelToDropdown(modelId, sel, preferredProviderId, opts){
if(!modelId||!sel) return null;
const currentState=(sel.id==='modelSelect'&&typeof _modelStateForSelect==='function')
? _modelStateForSelect(sel, sel.value)
: null;
const resolved=_findModelInDropdown(modelId,sel,preferredProviderId);
if(resolved){
sel.value=resolved;
if(sel.id==='modelSelect'){
const resolvedState=typeof _modelStateForSelect==='function'
? _modelStateForSelect(sel, resolved)
: {model:resolved,model_provider:preferredProviderId||null};
const pickerChanged= !!(opts&&opts.forceRefresh) || !currentState
|| String(currentState.model||'')!==String(resolvedState.model||'')
|| String(currentState.model_provider||'')!==String(resolvedState.model_provider||'');
if(typeof syncModelChip==='function') syncModelChip();
_refreshOpenModelDropdown();
if(pickerChanged) _refreshOpenModelDropdown();
}
return resolved;
}
@@ -2051,14 +2060,21 @@ function _addLiveModelsToSelect(provider, models, sel){
_dynamicModelLabels[mid]=m.label||m.id;
added++;
}
const currentProvider=(S.session&&S.session.model_provider)||null;
if(added>0 && currentVal) _applyModelToDropdown(currentVal, sel, currentProvider);
const currentState=(currentVal&&typeof _modelStateForSelect==='function')
? _modelStateForSelect(sel, currentVal)
: {model:currentVal||'', model_provider:(S.session&&S.session.model_provider)||null};
const currentProvider=currentState&&currentState.model_provider||null;
if(added>0 && currentVal) _applyModelToDropdown(currentVal, sel, currentProvider, {forceRefresh:true});
// After live models are added, re-apply the session's model in case it was
// absent from the static list and syncTopbar() fired before the live fetch
// completed (#1169). This ensures the session model wins over any premature
// fallback that may have set sel.value to the first available option.
if(S.session && S.session.model && sel.id==='modelSelect'){
const reapplied=_applyModelToDropdown(S.session.model, sel, S.session.model_provider||null);
const sessionProvider=S.session.model_provider||null;
const sessionAlreadyRefreshed=added>0 && currentVal
&& String((currentState&&currentState.model)||'')===String(S.session.model||'')
&& String((currentState&&currentState.model_provider)||'')===String(sessionProvider||'');
const reapplied=_applyModelToDropdown(S.session.model, sel, sessionProvider, {forceRefresh:added>0&&!sessionAlreadyRefreshed});
if(reapplied && typeof syncModelChip==='function') syncModelChip();
}
return added;
@@ -44,12 +44,39 @@ function extractFunc(name, opts = {}) {
}
const calls = {syncModelChip: 0, renderModelDropdown: 0, positionModelDropdown: 0, fetches: []};
const _dynamicModelLabels = {};
let modelSelect;
let dropdownOpen = false;
const dropdown = {classList: {contains: (name) => name === 'open' && dropdownOpen}};
function makeOptGroup(provider, label) {
return {
tagName: 'OPTGROUP',
label: label || '',
dataset: {provider: provider || ''},
children: [],
appendChild(opt) {
opt.parentElement = this;
this.children.push(opt);
if (this._select && !this._select.options.includes(opt)) {
this._select.options.push(opt);
}
}
};
}
function makeOption(value, label, provider) {
return {
value,
textContent: label || value,
dataset: provider ? {provider} : {},
title: '',
parentElement: null,
};
}
function makeSelect(options, initialValue) {
const sel = {id: 'modelSelect', options: [], selectedIndex: -1, selectedOptions: []};
const sel = {id: 'modelSelect', options: [], selectedIndex: -1, selectedOptions: [], children: []};
Object.defineProperty(sel, 'value', {
get() { return this._value || ''; },
set(v) {
@@ -59,11 +86,36 @@ function makeSelect(options, initialValue) {
this.selectedOptions = idx >= 0 ? [this.options[idx]] : [];
}
});
sel.appendChild = function(node) {
if (!node) return node;
if (node.tagName === 'OPTGROUP') {
node._select = this;
this.children.push(node);
for (const child of node.children || []) {
if (!this.options.includes(child)) this.options.push(child);
}
return node;
}
node.parentElement = node.parentElement || null;
this.options.push(node);
return node;
};
sel.querySelectorAll = function(selector) {
if (selector === 'optgroup') return this.children.filter(child => child.tagName === 'OPTGROUP');
return [];
};
sel.querySelector = function(_selector) { return this.options[0] || null; };
const groups = new Map();
for (const item of options) {
const group = {tagName: 'OPTGROUP', dataset: {provider: item.provider || ''}};
const opt = {value: item.value, textContent: item.label || item.value, parentElement: group, dataset: {}};
sel.options.push(opt);
const provider = item.provider || '';
let group = groups.get(provider);
if (!group) {
group = makeOptGroup(provider, provider);
groups.set(provider, group);
sel.appendChild(group);
}
const opt = makeOption(item.value, item.label || item.value, provider);
group.appendChild(opt);
}
sel.value = initialValue || '';
return sel;
@@ -91,7 +143,12 @@ const _liveModelFetchPending = new Set();
const document = {
title: '',
baseURI: 'http://127.0.0.1/hermes/',
createElement(tag) { return {tagName: tag.toUpperCase(), className: '', textContent: '', appendChild(){}}; },
createElement(tag) {
const upper = String(tag || '').toUpperCase();
if (upper === 'OPTGROUP') return makeOptGroup('', '');
if (upper === 'OPTION') return makeOption('', '', '');
return {tagName: upper, className: '', textContent: '', dataset: {}, appendChild(){}};
},
createTextNode(text) { return {textContent: text}; },
};
const window = { _botName: 'Hermes', _defaultModel: null, _activeProvider: null };
@@ -102,6 +159,7 @@ for (const name of [
'_topbarLoadedMessageCount', '_topbarMessageMetaText',
'_getOptionProviderId', '_providerFromModelValue', '_modelStateForSelect',
'_findModelInDropdown', '_refreshOpenModelDropdown', '_applyModelToDropdown',
'_addLiveModelsToSelect',
'_modelStateFromAppliedDropdown', '_persistSessionModelCorrection',
'_applySessionModelFallback', 'syncTopbar'
]) {
@@ -128,12 +186,20 @@ var S = {
activeProfile: 'default',
};
syncTopbar();
if (args.preapplyModel) {
_applyModelToDropdown(args.preapplyModel, modelSelect, args.preapplyProvider || null);
}
if (args.liveProvider && Array.isArray(args.liveModels)) {
_addLiveModelsToSelect(args.liveProvider, args.liveModels, modelSelect);
} else {
syncTopbar();
}
process.stdout.write(JSON.stringify({
selectValue: modelSelect.value,
sessionModel: S.session.model,
sessionProvider: S.session.model_provider,
optionValues: modelSelect.options.map(o => o.value),
calls,
}));
"""
@@ -146,7 +212,17 @@ def driver_path(tmp_path_factory):
return str(p)
def _run_sync(driver_path, *, session_model, initial_value="@expensive:gpt-5.5", default_model="@safe:gpt-4o-mini", dropdown_open=False, model_resolution_deferred=False):
def _run_sync(
driver_path,
*,
session_model,
initial_value="@expensive:gpt-5.5",
default_model="@safe:gpt-4o-mini",
dropdown_open=False,
model_resolution_deferred=False,
preapply_model=None,
preapply_provider=None,
):
payload = {
"sessionModel": session_model,
"sessionProvider": None,
@@ -155,6 +231,43 @@ def _run_sync(driver_path, *, session_model, initial_value="@expensive:gpt-5.5",
"activeProvider": "safe",
"dropdownOpen": dropdown_open,
"modelResolutionDeferred": model_resolution_deferred,
"preapplyModel": preapply_model,
"preapplyProvider": preapply_provider,
"options": [
{"provider": "expensive", "value": "@expensive:gpt-5.5", "label": "GPT-5.5"},
{"provider": "safe", "value": "@safe:gpt-4o-mini", "label": "GPT-4o mini"},
],
}
result = subprocess.run(
[NODE, driver_path, str(UI_JS_PATH), json.dumps(payload)],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"node driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}")
return json.loads(result.stdout)
def _run_add_live_models(
driver_path,
*,
session_model,
initial_value,
live_provider,
live_models,
session_provider=None,
dropdown_open=False,
):
payload = {
"sessionModel": session_model,
"sessionProvider": session_provider,
"initialValue": initial_value,
"defaultModel": "@safe:gpt-4o-mini",
"activeProvider": live_provider,
"dropdownOpen": dropdown_open,
"liveProvider": live_provider,
"liveModels": live_models,
"options": [
{"provider": "expensive", "value": "@expensive:gpt-5.5", "label": "GPT-5.5"},
{"provider": "safe", "value": "@safe:gpt-4o-mini", "label": "GPT-4o mini"},
@@ -192,8 +305,55 @@ def test_sync_topbar_rerenders_open_visible_model_dropdown_after_session_model_c
got = _run_sync(driver_path, session_model="", dropdown_open=True)
assert got["selectValue"] == "@safe:gpt-4o-mini"
assert got["calls"]["renderModelDropdown"] >= 1
assert got["calls"]["positionModelDropdown"] >= 1
assert got["calls"]["renderModelDropdown"] == 1
assert got["calls"]["positionModelDropdown"] == 1
def test_sync_topbar_does_not_rerender_open_visible_model_dropdown_when_session_model_unchanged(driver_path):
got = _run_sync(
driver_path,
session_model="@safe:gpt-4o-mini",
initial_value="@safe:gpt-4o-mini",
dropdown_open=True,
)
assert got["selectValue"] == "@safe:gpt-4o-mini"
assert got["sessionModel"] == "@safe:gpt-4o-mini"
assert got["calls"]["renderModelDropdown"] == 0
assert got["calls"]["positionModelDropdown"] == 0
def test_sync_topbar_rerender_count_remains_one_for_preapply_plus_sync(driver_path):
got = _run_sync(
driver_path,
session_model="@safe:gpt-4o-mini",
initial_value="@expensive:gpt-5.5",
dropdown_open=True,
preapply_model="@safe:gpt-4o-mini",
preapply_provider="safe",
)
assert got["selectValue"] == "@safe:gpt-4o-mini"
assert got["sessionModel"] == "@safe:gpt-4o-mini"
assert got["calls"]["renderModelDropdown"] == 1
assert got["calls"]["positionModelDropdown"] == 1
def test_live_model_reapply_refreshes_open_dropdown_after_catalog_growth(driver_path):
got = _run_add_live_models(
driver_path,
session_model="@safe:gpt-4o-mini",
session_provider="safe",
initial_value="@safe:gpt-4o-mini",
live_provider="safe",
live_models=[{"id": "gpt-4.1", "label": "GPT-4.1"}],
dropdown_open=True,
)
assert got["selectValue"] == "@safe:gpt-4o-mini"
assert "@safe:gpt-4.1" in got["optionValues"]
assert got["calls"]["renderModelDropdown"] == 1
assert got["calls"]["positionModelDropdown"] == 1