feat(#5497): reuse the rich model picker in Preferences

This commit is contained in:
Rod Boev committed 2026-07-03 22:32:50 -04:00
1 parent 5e0d4d1e3d
commit 087c694c8d
7 files changed
+290 -20

No files matched your search

+2
View File
@@ -1136,8 +1136,10 @@
<div class="settings-field">
<label for="settingsModel" data-i18n="settings_label_model">Default Model</label>
<div class="model-advanced-row">
<button type="button" id="settingsModelChip" class="settings-model-chip" aria-haspopup="listbox" aria-expanded="false">Default Model</button>
<select id="settingsModel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
<button type="button" id="mainAdvancedBtn" class="model-advanced-btn" title="Advanced options" aria-label="Advanced options for main model"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
<div class="model-dropdown settings-model-dropdown" id="settingsModelDropdown"></div>
</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_model">Used for new conversations. Existing conversations keep their selected model.</div>
</div>
+6
View File
@@ -8644,7 +8644,13 @@ async function loadSettingsPanel(){
}else{
modelSel.value=_settingsHermesDefaultModelOnOpen;
}
if(typeof closeSettingsModelDropdown==='function') closeSettingsModelDropdown();
if(typeof mountSettingsModelPicker==='function') mountSettingsModelPicker();
modelSel.addEventListener('change',_markSettingsDirty,{once:false});
if(!modelSel._settingsChipSyncBound){
modelSel._settingsChipSyncBound=true;
modelSel.addEventListener('change',()=>{if(typeof syncSettingsModelChip==='function') syncSettingsModelChip();},{once:false});
}
}
// Auxiliary models — load task assignments and provider/model options
_bindMainAdvancedOptionsButton();
+39
View File
@@ -4840,16 +4840,55 @@ main.main > #mainPlugin{display:none;}
#mainSettings .settings-field > div[style*="color: var(--muted)"]{font-size:12px;color:var(--muted);line-height:1.5;}
.model-advanced-row{
position:relative;
display:grid;
grid-template-columns:minmax(0,1fr) 34px;
gap:8px;
align-items:center;
}
.model-advanced-row #settingsModel{
display:none;
width:100%!important;
min-width:0;
box-sizing:border-box;
}
.settings-model-chip{
width:100%;
min-width:0;
box-sizing:border-box;
padding:8px 10px;
border:1px solid var(--border2);
border-radius:6px;
background:var(--code-bg);
color:var(--text);
font:inherit;
font-size:13px;
text-align:left;
cursor:pointer;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.settings-model-chip:hover,.settings-model-chip.active,.settings-model-chip:focus-visible{
border-color:var(--accent);
outline:none;
}
.settings-model-dropdown{
top:calc(100% + 4px);
bottom:auto;
left:0;
width:calc(100% - 42px);
min-width:min(360px,100%);
max-width:100%;
max-height:360px;
z-index:260;
}
.settings-model-dropdown .model-search-row{
position:sticky;
top:0;
background:var(--surface);
z-index:1;
}
.model-advanced-btn{
width:32px;
height:32px;
+135 -16
View File
@@ -2430,23 +2430,36 @@ function _refreshOpenModelDropdown(){
renderModelDropdown();
if(typeof _positionModelDropdown==='function') _positionModelDropdown();
}
const sdd=$('settingsModelDropdown');
if(sdd&&sdd.classList&&sdd.classList.contains('open')&&typeof renderModelDropdown==='function'){
renderModelDropdown({
dropdownId:'settingsModelDropdown',
selectId:'settingsModel',
forceOpenKey:'settingsModel',
closeDropdown:closeSettingsModelDropdown,
selectModel:selectSettingsModelFromDropdown,
scopeNoteText:t('settings_desc_model')||'Used for new conversations. Existing conversations keep their selected model.',
});
}
}
function _applyModelToDropdown(modelId, sel, preferredProviderId, opts){
if(!modelId||!sel) return null;
const currentState=(sel.id==='modelSelect'&&typeof _modelStateForSelect==='function')
const isRichPickerSelect=sel.id==='modelSelect'||sel.id==='settingsModel';
const currentState=(isRichPickerSelect&&typeof _modelStateForSelect==='function')
? _modelStateForSelect(sel, sel.value)
: null;
const resolved=_findModelInDropdown(modelId,sel,preferredProviderId);
if(resolved){
sel.value=resolved;
if(sel.id==='modelSelect'){
if(isRichPickerSelect){
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();
if(sel.id==='modelSelect'&&typeof syncModelChip==='function') syncModelChip();
if(sel.id==='settingsModel'&&typeof syncSettingsModelChip==='function') syncSettingsModelChip();
if(pickerChanged) _refreshOpenModelDropdown();
}
return resolved;
@@ -2472,6 +2485,10 @@ function _ensureModelOptionInDropdown(modelId, sel, preferredProviderId){
if(typeof syncModelChip==='function') syncModelChip();
_refreshOpenModelDropdown();
}
if(sel.id==='settingsModel'){
if(typeof syncSettingsModelChip==='function') syncSettingsModelChip();
_refreshOpenModelDropdown();
}
return modelId;
}
function _modelStateFromAppliedDropdown(sel, modelValue){
@@ -3078,9 +3095,16 @@ function _mountSearchableModelSelect(opts={}){
}
function renderModelDropdown(){
const dd=$('composerModelDropdown');
const sel=$('modelSelect');
const opts=arguments[0]||{};
const dd=$(opts.dropdownId||'composerModelDropdown');
const sel=$(opts.selectId||'modelSelect');
if(!dd||!sel) return;
const selectFromDropdown=typeof opts.selectModel==='function'
? opts.selectModel
: (value,provider)=>selectModelFromDropdown(value,provider);
const closeDropdown=typeof opts.closeDropdown==='function'
? opts.closeDropdown
: closeModelDropdown;
// Group(s) that must render OPEN even though they aren't the selected group —
// set when the user expands a group's overflow via "Show more" so a later full
// re-render doesn't re-collapse it (_groupOpenState is rebuilt per render, so
@@ -3089,8 +3113,10 @@ function renderModelDropdown(){
// #3691 node test driver evals the function body without module scope).
const _forceOpenGroups=(()=>{
const _g=(typeof window!=='undefined')?window:(typeof globalThis!=='undefined'?globalThis:{});
if(!_g.__modelGroupForceOpen) _g.__modelGroupForceOpen=new Set();
return _g.__modelGroupForceOpen;
const key=opts.forceOpenKey||'composer';
if(!_g.__modelGroupForceOpenByPicker) _g.__modelGroupForceOpenByPicker={};
if(!_g.__modelGroupForceOpenByPicker[key]) _g.__modelGroupForceOpenByPicker[key]=new Set();
return _g.__modelGroupForceOpenByPicker[key];
})();
const _modelData=[];
const _groupMeta=new Map();
@@ -3187,7 +3213,7 @@ function renderModelDropdown(){
// Create search input FIRST before filterModels definition
const _scopeNote=document.createElement('div');
_scopeNote.className='model-scope-note';
_scopeNote.textContent=t('model_scope_advisory')||'Applies to this conversation from your next message.';
_scopeNote.textContent=opts.scopeNoteText||(t('model_scope_advisory')||'Applies to this conversation from your next message.');
const _searchRow=document.createElement('div');
_searchRow.className='model-search-row';
_searchRow.innerHTML=`<input class="model-search-input" type="text" placeholder="${esc(t('model_search_placeholder')||'Search models…')}" spellcheck="false" autocomplete="off"><button class="model-search-clear" title="Clear search">${li('x',10)}</button>`;
@@ -3227,7 +3253,7 @@ function renderModelDropdown(){
const _plainGroup=m.group?String(m.group).replace(/\s*\(\d+\s+of\s+\d+\)\s*$/,''):'';
const providerChip=(_plainGroup&&withProviderChip)?`<span class="model-opt-provider">${esc(_plainGroup)}</span>`:'';
row.innerHTML=`<div class="model-opt-top"><span class="model-opt-name">${esc(m.name)}</span>${badgeHtml}${providerChip}</div><span class="model-opt-id">${esc(m.id)}</span>`;
row.onclick=()=>selectModelFromDropdown(m.value,m.providerId||(m.badge&&m.badge.provider)||null);
row.onclick=()=>selectFromDropdown(m.value,m.providerId||(m.badge&&m.badge.provider)||null);
return row;
};
const _expandOverflowGroup=(groupMetaEntry)=>{
@@ -3251,7 +3277,7 @@ function renderModelDropdown(){
// expander gone, search term reapplied.
const _fullReRender=()=>{
const _term=(_si&&_si.value)||'';
renderModelDropdown();
renderModelDropdown(opts);
const ns=dd.querySelector('.model-search-input');
if(ns){ ns.value=_term; (ns._listeners&&ns._listeners.input)?ns._listeners.input():ns.dispatchEvent(new Event('input')); }
};
@@ -3280,7 +3306,7 @@ function renderModelDropdown(){
for(const m of extraModels){
if(!m||!m.id) continue;
if(_alreadyShown.has(esc(m.id))) continue;
const row=_buildModelRow({value:m.id,name:m.label||m.id,id:m.id,group:_plainLabel,groupKey,providerId:(og.dataset&&og.dataset.provider)||''},$('modelSelect'),false);
const row=_buildModelRow({value:m.id,name:m.label||m.id,id:m.id,group:_plainLabel,groupKey,providerId:(og.dataset&&og.dataset.provider)||''},sel,false);
wrap.insertBefore(row,moreEl);
if(!firstNewRow) firstNewRow=row;
}
@@ -3346,7 +3372,7 @@ function renderModelDropdown(){
const _underOwnHeading=shouldRenderHeading&&!!(m.groupKey&&_groupWrappers[m.groupKey]);
const providerChip=(_plainGroup&&!_underOwnHeading)?`<span class="model-opt-provider">${esc(_plainGroup)}</span>`:'';
row.innerHTML=`<div class="model-opt-top"><span class="model-opt-name">${esc(m.name)}</span>${badgeHtml}${providerChip}</div><span class="model-opt-id">${esc(m.id)}</span>`;
row.onclick=()=>selectModelFromDropdown(m.value,m.providerId||(m.badge&&m.badge.provider)||null);
row.onclick=()=>selectFromDropdown(m.value,m.providerId||(m.badge&&m.badge.provider)||null);
return row;
};
const _filterModels=(term)=>{
@@ -3441,7 +3467,7 @@ function renderModelDropdown(){
}
const badgeHtml=m.badge?`<span class="model-opt-badge model-opt-badge--${esc(m.badge.role||'configured')}">${esc(badgeLabel)}</span>`:'';
row.innerHTML=`<div class="model-opt-top"><span class="model-opt-name">${esc(modelName)}</span>${badgeHtml}</div><span class="model-opt-id">${esc(m.id)}</span>`;
row.onclick=()=>selectModelFromDropdown(m.value,(m.badge&&m.badge.provider)||m.providerId||null);
row.onclick=()=>selectFromDropdown(m.value,(m.badge&&m.badge.provider)||m.providerId||null);
dd.appendChild(row);
}
}
@@ -3609,7 +3635,7 @@ function renderModelDropdown(){
if(typeof row.scrollIntoView==='function') row.scrollIntoView({block:'nearest'});
};
_si.addEventListener('keydown',e=>{
if(e.key==='Escape'){closeModelDropdown();return;}
if(e.key==='Escape'){closeDropdown();return;}
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){
const rows=_visibleModelRows();
if(!rows.length){if(e.key==='Enter') e.preventDefault();return;}
@@ -3626,9 +3652,9 @@ function renderModelDropdown(){
_si.addEventListener('click',e=>e.stopPropagation());
_sc.onclick=()=>{ _si.value=''; _filterModels(''); _si.focus(); };
_sc.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){ _si.value=''; _filterModels(''); _si.focus(); e.preventDefault(); }});
const _applyCustom=()=>{const v=_ci.value.trim();if(!v)return;selectModelFromDropdown(v);_ci.value='';};
const _applyCustom=()=>{const v=_ci.value.trim();if(!v)return;selectFromDropdown(v,null);_ci.value='';};
_cb.onclick=_applyCustom;
_ci.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();_applyCustom();}if(e.key==='Escape'){closeModelDropdown();}});
_ci.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();_applyCustom();}if(e.key==='Escape'){closeDropdown();}});
_ci.addEventListener('click',e=>e.stopPropagation());
dd.appendChild(_scopeNote);
dd.appendChild(_searchRow);
@@ -3693,12 +3719,105 @@ function closeModelDropdown(){
if(mobileAction) mobileAction.classList.remove('active');
}
function closeSettingsModelDropdown(){
const dd=$('settingsModelDropdown');
const chip=$('settingsModelChip');
if(dd) dd.classList.remove('open');
if(chip){
chip.classList.remove('active');
chip.setAttribute('aria-expanded','false');
}
}
function syncSettingsModelChip(){
const sel=$('settingsModel');
const chip=$('settingsModelChip');
if(!sel||!chip) return;
const opt=sel.selectedOptions&&sel.selectedOptions[0];
const text=(opt&&opt.textContent)||getModelLabel(sel.value||'')||t('settings_label_model')||'Default Model';
chip.textContent=text;
chip.title=sel.value||text;
}
function selectSettingsModelFromDropdown(value,preferredProviderId){
const sel=$('settingsModel');
if(!sel){closeSettingsModelDropdown();return;}
const provider=String(preferredProviderId||'').trim()||null;
if(typeof _ensureModelOptionInDropdown==='function'){
_ensureModelOptionInDropdown(value,sel,provider);
}else{
sel.value=value;
if(typeof syncSettingsModelChip==='function') syncSettingsModelChip();
}
closeSettingsModelDropdown();
try{
if(typeof Event==='function') sel.dispatchEvent(new Event('change',{bubbles:true}));
else if(typeof sel.onchange==='function') sel.onchange();
}catch(_){}
}
function openSettingsModelDropdown(){
const dd=$('settingsModelDropdown');
const sel=$('settingsModel');
const chip=$('settingsModelChip');
if(!dd||!sel) return;
renderModelDropdown({
dropdownId:'settingsModelDropdown',
selectId:'settingsModel',
forceOpenKey:'settingsModel',
closeDropdown:closeSettingsModelDropdown,
selectModel:selectSettingsModelFromDropdown,
scopeNoteText:t('settings_desc_model')||'Used for new conversations. Existing conversations keep their selected model.',
});
dd.classList.add('open');
if(chip){
chip.classList.add('active');
chip.setAttribute('aria-expanded','true');
}
setTimeout(()=>{
const input=dd.querySelector('.model-search-input');
if(input) input.focus();
},0);
}
function toggleSettingsModelDropdown(){
const dd=$('settingsModelDropdown');
if(dd&&dd.classList.contains('open')){closeSettingsModelDropdown();return;}
openSettingsModelDropdown();
}
function mountSettingsModelPicker(){
const chip=$('settingsModelChip');
const sel=$('settingsModel');
if(!chip||!sel) return;
syncSettingsModelChip();
if(!chip._settingsModelPickerBound){
chip._settingsModelPickerBound=true;
chip.addEventListener('click',e=>{
e.preventDefault();
e.stopPropagation();
toggleSettingsModelDropdown();
});
chip.addEventListener('keydown',e=>{
if(e.key==='Enter'||e.key===' '||e.key==='ArrowDown'){
e.preventDefault();
toggleSettingsModelDropdown();
}
});
}
}
document.addEventListener('click',e=>{
if(
!e.target.closest('#composerModelChip') &&
!e.target.closest('#composerMobileModelAction') &&
!e.target.closest('#composerModelDropdown')
) closeModelDropdown();
if(
!e.target.closest('#settingsModelChip') &&
!e.target.closest('#settingsModel') &&
!e.target.closest('#settingsModelDropdown')
) closeSettingsModelDropdown();
});
window.addEventListener('resize',()=>{
const dd=$('composerModelDropdown');
+1 -1
View File
@@ -24,7 +24,7 @@ def test_highlight_class_has_style():
def test_escape_still_closes_dropdown():
# Existing Escape-to-close behavior must not regress.
assert "if(e.key==='Escape'){closeModelDropdown();return;}" in UI_JS
assert "if(e.key==='Escape'){closeDropdown();return;}" in UI_JS
def test_enter_picks_highlighted_row():
@@ -135,10 +135,13 @@ def test_selectModelFromDropdown_defers_reasoning_sync_to_onchange():
def test_model_dropdown_passes_provider_to_select():
"""Composer model rows must pass provider context into selectModelFromDropdown."""
"""Composer model rows must pass provider context through the shared picker callback."""
with open("static/ui.js") as f:
src = f.read()
assert "selectModelFromDropdown(value,provider)" in src, (
"composer default picker callback must still route to selectModelFromDropdown"
)
assert re.search(
r"selectModelFromDropdown\(m\.value,\s*m\.providerId",
r"selectFromDropdown\(m\.value,\s*m\.providerId",
src,
), "model dropdown rows must pass providerId to selectModelFromDropdown"
), "model dropdown rows must pass providerId through the shared callback"
@@ -0,0 +1,101 @@
"""Regression coverage for #5497 Preferences default model picker parity."""
from __future__ import annotations
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
def _function_body(src: str, name: str) -> str:
match = re.search(rf"function\s+{re.escape(name)}\s*\([^)]*\)\s*\{{", src)
assert match, f"{name} not found"
start = match.end()
depth = 1
pos = start
while pos < len(src) and depth:
if src[pos] == "{":
depth += 1
elif src[pos] == "}":
depth -= 1
pos += 1
assert depth == 0, f"{name} body did not terminate"
return src[start : pos - 1]
def test_preferences_default_model_field_keeps_source_select_and_adds_rich_picker_shell():
field_start = INDEX_HTML.index('id="settingsModelChip"')
field_end = INDEX_HTML.index('data-i18n="settings_desc_model"', field_start)
field = INDEX_HTML[field_start:field_end]
assert 'id="settingsModelChip"' in field
assert 'id="settingsModel"' in field
assert 'id="settingsModelDropdown"' in field
assert 'class="model-dropdown settings-model-dropdown"' in field
assert 'id="mainAdvancedBtn"' in field
assert field.index('id="settingsModelChip"') < field.index('id="mainAdvancedBtn"')
def test_settings_model_select_is_hidden_but_remains_advanced_options_source():
assert ".model-advanced-row #settingsModel" in STYLE_CSS
assert "display:none;" in STYLE_CSS[STYLE_CSS.index(".model-advanced-row #settingsModel") : STYLE_CSS.index(".model-advanced-btn")]
bind_body = _function_body(PANELS_JS, "_bindMainAdvancedOptionsButton")
assert "const modelSel=$('settingsModel');" in bind_body
assert "modelSel.parentElement" in bind_body
assert "mainAdvancedBtn" in bind_body
def test_render_model_dropdown_accepts_settings_select_and_callbacks():
body = _function_body(UI_JS, "renderModelDropdown")
assert "opts.dropdownId||'composerModelDropdown'" in body
assert "opts.selectId||'modelSelect'" in body
assert "opts.selectModel" in body
assert "opts.closeDropdown" in body
assert "selectFromDropdown(m.value" in body
assert "row.onclick=()=>selectModelFromDropdown" not in body
assert "closeDropdown();return;" in body
assert "opts.forceOpenKey||'composer'" in body
assert "renderModelDropdown(opts)" in body
assert "opts.scopeNoteText||" in body
def test_preferences_picker_routes_through_shared_renderer_without_panel_copy():
mount_body = _function_body(UI_JS, "openSettingsModelDropdown")
select_body = _function_body(UI_JS, "selectSettingsModelFromDropdown")
panels_model_block = PANELS_JS[
PANELS_JS.index("const modelSel=$('settingsModel');") : PANELS_JS.index("// Auxiliary models", PANELS_JS.index("const modelSel=$('settingsModel');"))
]
assert "renderModelDropdown({" in mount_body
assert "dropdownId:'settingsModelDropdown'" in mount_body
assert "selectId:'settingsModel'" in mount_body
assert "selectModel:selectSettingsModelFromDropdown" in mount_body
assert "scopeNoteText:t('settings_desc_model')" in mount_body
assert "_ensureModelOptionInDropdown(value,sel,provider)" in select_body
assert "sel.dispatchEvent(new Event('change',{bubbles:true}))" in select_body
assert "syncSettingsModelChip" in select_body
assert "closeSettingsModelDropdown" in panels_model_block
assert "mountSettingsModelPicker" in panels_model_block
assert "_settingsChipSyncBound" in panels_model_block
assert ".model-opt" not in PANELS_JS
assert "model-search-input" not in PANELS_JS
def test_preferences_picker_refreshes_existing_save_and_dirty_contracts():
autosave_body = _function_body(PANELS_JS, "_autosavePreferencesSettings")
save_body = _function_body(PANELS_JS, "saveSettings")
refresh_body = _function_body(UI_JS, "_refreshOpenModelDropdown")
assert "_captureModelDropdownSelection(modelSel)" in autosave_body
assert "_captureModelDropdownSelection($('settingsModel'))" in save_body
assert "await api('/api/default-model',{method:'POST',body:JSON.stringify({model,provider:modelState.model_provider||null})});" in save_body
assert "settingsModelDropdown" in refresh_body
assert "selectModel:selectSettingsModelFromDropdown" in refresh_body