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 c0f30cea..45381a8d 100644 --- a/static/index.html +++ b/static/index.html @@ -1134,7 +1134,7 @@
- +
diff --git a/static/style.css b/static/style.css index bcbf4ace..d6acf7c3 100644 --- a/static/style.css +++ b/static/style.css @@ -4899,6 +4899,9 @@ main.main > #mainPlugin{display:none;} 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; diff --git a/static/ui.js b/static/ui.js index 877257bc..571ecb27 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2601,6 +2601,10 @@ function _refreshOpenModelDropdown(){ } 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', @@ -2608,6 +2612,7 @@ function _refreshOpenModelDropdown(){ closeDropdown:closeSettingsModelDropdown, selectModel:selectSettingsModelFromDropdown, scopeNoteText:t('settings_desc_model')||'Used for new conversations. Existing conversations keep their selected model.', + autoFocusSearch:!_coarsePointer, }); } } @@ -3268,6 +3273,10 @@ function renderModelDropdown(){ 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); @@ -3545,6 +3554,10 @@ function renderModelDropdown(){ 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). @@ -3783,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). @@ -3930,6 +3943,11 @@ function openSettingsModelDropdown(){ 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', @@ -3937,16 +3955,19 @@ function openSettingsModelDropdown(){ 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'); } - setTimeout(()=>{ - const input=dd.querySelector('.model-search-input'); - if(input) input.focus(); - },0); + if(!_coarsePointer){ + setTimeout(()=>{ + const input=dd.querySelector('.model-search-input'); + if(input) input.focus(); + },0); + } } function toggleSettingsModelDropdown(){ diff --git a/tests/test_issue5497_preferences_model_picker.py b/tests/test_issue5497_preferences_model_picker.py index aae665fa..7c5f1666 100644 --- a/tests/test_issue5497_preferences_model_picker.py +++ b/tests/test_issue5497_preferences_model_picker.py @@ -99,3 +99,41 @@ def test_preferences_picker_refreshes_existing_save_and_dirty_contracts(): 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"