diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9696cce5..cb229c1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@
### Added
+- **The Preferences default-model setting now uses the rich model picker.** The "Default Model" control in Settings → Preferences was a plain dropdown; it now uses the same searchable, provider-grouped picker as the composer (with a "Custom Model ID" field), so choosing a default model is consistent everywhere. On touch devices the picker no longer pops the keyboard the instant you open it (it still focuses search on desktop and while you're typing), and its label now activates the picker directly. Thanks @rodboev. (#5502, #5497)
- **Scheduled jobs get a preset schedule builder.** The cron job form now has a "Preset" dropdown (Hourly / Daily / Weekdays / Weekly / Monthly, plus Custom) above the Schedule field — pick one and it fills the schedule for you (e.g. Daily → `0 9 * * *`, Hourly → `every 1h`) instead of hand-writing a cron expression. "Custom" keeps the free-text field, and editing an existing job maps its schedule back to the matching preset. Thanks @rodboev. (#5438, #5427)
- **Add a self-hosted provider (Ollama or LM Studio) from Settings.** After onboarding, a new Settings control lets you point WebUI at a local OpenAI-compatible model server (Ollama / LM Studio) by choosing the provider, model, and base URL — no hand-editing `config.yaml`. The base URL is validated (scheme + provider allowlist) and persisted for the agent's provider client. An optional "Test connection" button reuses the existing authenticated onboarding probe to fetch the model list from the URL you enter. Thanks @rodboev. (#5408, #3260)
- **Copy a workspace file's relative path.** The workspace file-tree right-click menu and the file-preview header now offer "Copy relative path" (e.g. `src/app/main.py`) alongside the existing absolute-path copy — handy for pasting a path into chat. On a narrow preview pane the header button folds to icon-only (with a localized tooltip + accessible name) so it never crowds out the path text or the Download/Edit actions. Thanks @rodboev. (#5548, #5533)
diff --git a/static/index.html b/static/index.html
index f392cc24..45381a8d 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1134,10 +1134,12 @@
-
+
+
+
Used for new conversations. Existing conversations keep their selected model.
diff --git a/static/panels.js b/static/panels.js
index 0a696db9..c841a4ef 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -8693,7 +8693,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();
diff --git a/static/style.css b/static/style.css
index a9899ab6..d6acf7c3 100644
--- a/static/style.css
+++ b/static/style.css
@@ -4857,16 +4857,58 @@ 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;
+ /* This picker opens DOWNWARD (top:100%), unlike the composer picker which opens
+ upward; override the inherited upward shadow so it casts down toward the list. */
+ box-shadow:0 4px 24px rgba(0,0,0,.4);
+}
+.settings-model-dropdown .model-search-row{
+ position:sticky;
+ top:0;
+ background:var(--surface);
+ z-index:1;
+}
.model-advanced-btn{
width:32px;
height:32px;
diff --git a/static/ui.js b/static/ui.js
index 7ae34d3a..571ecb27 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -2599,23 +2599,41 @@ function _refreshOpenModelDropdown(){
renderModelDropdown();
if(typeof _positionModelDropdown==='function') _positionModelDropdown();
}
+ const sdd=$('settingsModelDropdown');
+ if(sdd&&sdd.classList&&sdd.classList.contains('open')&&typeof renderModelDropdown==='function'){
+ // Re-rendering the OPEN settings picker (e.g. when a late live-model fetch
+ // resolves) must not re-grab search focus on touch — same coarse-pointer rule
+ // as openSettingsModelDropdown, or the mobile keyboard pops after opening.
+ const _coarsePointer=(typeof window.matchMedia==='function')&&window.matchMedia('(pointer: coarse)').matches;
+ 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.',
+ autoFocusSearch:!_coarsePointer,
+ });
+ }
}
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;
@@ -2641,6 +2659,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){
@@ -3247,9 +3269,20 @@ 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;
+ // Whether the search input should auto-grab focus on (re-)render. Default true
+ // preserves the composer picker's behavior exactly; the settings picker passes
+ // false on coarse-pointer devices so opening it doesn't pop the mobile keyboard.
+ const _autoFocusSearch=opts.autoFocusSearch!==false;
+ 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
@@ -3258,8 +3291,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();
@@ -3356,7 +3391,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=``;
@@ -3396,7 +3431,7 @@ function renderModelDropdown(){
const _plainGroup=m.group?String(m.group).replace(/\s*\(\d+\s+of\s+\d+\)\s*$/,''):'';
const providerChip=(_plainGroup&&withProviderChip)?`${esc(_plainGroup)}`:'';
row.innerHTML=`
${esc(m.id)}`;
- 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)=>{
+ // Preserve focus across the re-render if the search input already had it — so a
+ // touch user typing a query (where autoFocusSearch is suppressed to avoid the
+ // initial keyboard pop) doesn't lose focus mid-word on each keystroke re-render.
+ const _hadFocus=(typeof document!=='undefined')&&document.activeElement===_si;
term=term.trim().toLowerCase();
const hasSearch=!!term;
// On a fresh search, expand all groups so every match is visible (#collapse).
@@ -3610,7 +3649,7 @@ function renderModelDropdown(){
}
const badgeHtml=m.badge?`${esc(badgeLabel)}`:'';
row.innerHTML=`
${esc(modelName)}${badgeHtml}
${esc(m.id)}`;
- 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);
}
}
@@ -3757,7 +3796,7 @@ function renderModelDropdown(){
noResult.style.textAlign='center';
dd.appendChild(noResult);
}
- _si.focus();
+ if(_autoFocusSearch||_hadFocus) _si.focus();
};
_si.addEventListener('input',()=>_filterModels(_si.value));
// Keyboard navigation through filtered model rows (#2791).
@@ -3778,7 +3817,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;}
@@ -3795,9 +3834,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);
@@ -3862,12 +3901,113 @@ 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;
+ // Auto-focus the search on desktop only. On touch (coarse pointer) grabbing focus
+ // pops the on-screen keyboard the instant the chip is tapped — the composer picker
+ // doesn't do it either, so match that behavior on touch. Computed before render so
+ // renderModelDropdown's own initial focus is suppressed too (not just the outer one).
+ const _coarsePointer=(typeof window.matchMedia==='function')&&window.matchMedia('(pointer: coarse)').matches;
+ 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.',
+ autoFocusSearch:!_coarsePointer,
+ });
+ dd.classList.add('open');
+ if(chip){
+ chip.classList.add('active');
+ chip.setAttribute('aria-expanded','true');
+ }
+ if(!_coarsePointer){
+ 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');
diff --git a/tests/test_2791_model_picker_keyboard_nav.py b/tests/test_2791_model_picker_keyboard_nav.py
index 74910024..1846c695 100644
--- a/tests/test_2791_model_picker_keyboard_nav.py
+++ b/tests/test_2791_model_picker_keyboard_nav.py
@@ -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():
diff --git a/tests/test_issue1103_reasoning_chip_visibility.py b/tests/test_issue1103_reasoning_chip_visibility.py
index 5c8ac758..a0acc0b7 100644
--- a/tests/test_issue1103_reasoning_chip_visibility.py
+++ b/tests/test_issue1103_reasoning_chip_visibility.py
@@ -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"
diff --git a/tests/test_issue5497_preferences_model_picker.py b/tests/test_issue5497_preferences_model_picker.py
new file mode 100644
index 00000000..7c5f1666
--- /dev/null
+++ b/tests/test_issue5497_preferences_model_picker.py
@@ -0,0 +1,139 @@
+"""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
+
+
+def test_preferences_picker_ux_fixes_label_touchfocus_shadow():
+ """Fable SHIP-WITH-UX-FIXES fold-in (#5502):
+ (1) the field label targets the interactive chip, not the hidden native select;
+ (2) the search auto-focus is skipped on coarse-pointer (no mobile keyboard pop);
+ (3) the downward-opening Preferences dropdown casts its shadow downward.
+ """
+ # (1) label association -> the visible/interactive chip
+ assert 'for="settingsModelChip"' in INDEX_HTML
+ assert 'for="settingsModel"' not in INDEX_HTML # no longer points at the hidden select
+
+ # (2) coarse-pointer guard around the search auto-focus in the settings open path,
+ # passed through to renderModelDropdown so its OWN initial focus is suppressed too.
+ open_body = _function_body(UI_JS, "openSettingsModelDropdown")
+ assert "matchMedia('(pointer: coarse)')" in open_body
+ assert "autoFocusSearch:!_coarsePointer" in open_body
+ guard_idx = open_body.index("matchMedia('(pointer: coarse)')")
+ render_idx = open_body.index("renderModelDropdown(")
+ assert guard_idx < render_idx, "coarse-pointer must be computed before the render call"
+ # renderModelDropdown honors the option and defaults it true (composer unchanged).
+ render_body = _function_body(UI_JS, "renderModelDropdown")
+ assert "opts.autoFocusSearch!==false" in render_body
+ assert "if(_autoFocusSearch||_hadFocus) _si.focus();" in render_body
+ # Focus is still restored during typing (touch user in the search) — the
+ # suppression must NOT drop focus mid-word on the per-keystroke re-render.
+ assert "document.activeElement===_si" in render_body
+ # The OPEN-picker refresh path (late live-model fetch) must also suppress focus
+ # on touch for the settings branch, or the keyboard pops after opening.
+ refresh_body = _function_body(UI_JS, "_refreshOpenModelDropdown")
+ settings_branch = refresh_body[refresh_body.index("settingsModelDropdown"):]
+ assert "autoFocusSearch:!_coarsePointer" in settings_branch
+ assert "matchMedia('(pointer: coarse)')" in settings_branch
+
+ # (3) downward shadow override on the settings dropdown block
+ block_start = STYLE_CSS.index(".settings-model-dropdown{")
+ block = STYLE_CSS[block_start : STYLE_CSS.index("}", block_start) + 1]
+ assert "box-shadow:0 4px 24px" in block, "settings dropdown should cast its shadow downward"