diff --git a/CHANGELOG.md b/CHANGELOG.md index f7c606ed..d6649be0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **Onboarding now has the same searchable model picker as Settings.** A fresh install (e.g. an OpenRouter-only first run) previously showed only a small default model list in the onboarding step — no search box and no way to enter a custom model ID — so picking a model not in the short list meant finishing setup and fixing it later. Onboarding now reuses the same searchable picker (with custom-model-ID entry) that Settings uses, instead of a separate onboarding-only dropdown that could drift. The custom-provider free-text branch is unchanged, and the plain-`

${t('onboarding_custom_model_help')}

`; } + if(typeof _mountSearchableModelSelect==='function'){ + return `
${t('onboarding_model_label')}

${t('onboarding_workspace_help')}

`; + } const options=choices.map(m=>``).join(''); return `

${t('onboarding_workspace_help')}

`; } @@ -356,8 +359,23 @@ function _renderOnboardingBody(){ ${_renderOnboardingModelField()}`; const wsSel=$('onboardingWorkspaceSelect'); if(wsSel && ONBOARDING.form.workspace) wsSel.value=ONBOARDING.form.workspace; - const modelSel=$('onboardingModelSelect'); - if(modelSel && ONBOARDING.form.model) modelSel.value=ONBOARDING.form.model; + const modelPickerRoot=$('onboardingModelPickerRoot'); + if(modelPickerRoot && typeof _mountSearchableModelSelect==='function'){ + _mountSearchableModelSelect({ + root:modelPickerRoot, + selectId:'onboardingModelSelect', + customInputId:'onboardingModelInput', + choices:_getOnboardingProviderModelChoices(), + selectedValue:ONBOARDING.form.model, + onModelChange:(value)=>{ ONBOARDING.form.model=(value||'').trim(); }, + }); + }else{ + // Fallback path (searchable picker unavailable): rehydrate the plain + // `+ + ``+ + ``+ + ``+ + `
${esc(t('model_custom_label')||'Custom model ID')}
`+ + `
`+ + ``+ + ``+ + `
`; + const searchInput=root.querySelector('.model-search-input'); + const clearButton=root.querySelector('.model-search-clear'); + const selectEl=selectId ? root.querySelector(`#${selectId}`) : root.querySelector('select'); + const customInput=customInputId ? root.querySelector(`#${customInputId}`) : root.querySelector('.model-custom-input'); + const customButton=root.querySelector('.model-custom-btn'); + if(!searchInput||!clearButton||!selectEl||!customInput||!customButton) return null; + + const noMatchesOption=document.createElement('option'); + noMatchesOption.value=''; + noMatchesOption.textContent='No matching models'; + noMatchesOption.disabled=true; + noMatchesOption.hidden=true; + selectEl.appendChild(noMatchesOption); + + for(const choice of choices){ + const option=document.createElement('option'); + option.value=choice.id; + option.textContent=choice.label; + selectEl.appendChild(option); + } + if(listedSelection){ + selectEl.value=listedSelection; + }else if(customSelection){ + selectEl.selectedIndex=-1; + }else if(choices.length){ + selectEl.value=choices[0].id; + onModelChange(lastListedValue); + } + customInput.value=customSelection; + + const applyFilter=()=>{ + const needle=(searchInput.value||'').trim().toLowerCase(); + let visibleCount=0; + for(const option of Array.from(selectEl.options)){ + if(option===noMatchesOption) continue; + const haystack=`${option.textContent||''} ${option.value||''}`.toLowerCase(); + const visible=!needle||haystack.includes(needle); + option.hidden=!visible; + if(visible) visibleCount++; + } + noMatchesOption.hidden=visibleCount!==0; + }; + + const applyCustomSelection=()=>{ + onModelChange((customInput.value||'').trim()); + }; + + searchInput.addEventListener('input', applyFilter); + clearButton.addEventListener('click', ()=>{ + searchInput.value=''; + applyFilter(); + searchInput.focus(); + }); + selectEl.addEventListener('change', ()=>{ + customInput.value=''; + lastListedValue=selectEl.value||lastListedValue; + onModelChange(lastListedValue); + }); + customInput.addEventListener('input', ()=>{ + const value=(customInput.value||'').trim(); + if(value){ + selectEl.selectedIndex=-1; + onModelChange(value); + return; + } + customInput.value=''; + if(lastListedValue){ + selectEl.value=lastListedValue; + onModelChange(lastListedValue); + return; + } + onModelChange(''); + }); + customInput.addEventListener('keydown', (event)=>{ + if(event.key!=='Enter') return; + event.preventDefault(); + applyCustomSelection(); + }); + customButton.addEventListener('click', (event)=>{ + event.preventDefault(); + applyCustomSelection(); + }); + applyFilter(); + return {searchInput,selectEl,customInput,customButton}; +} + function renderModelDropdown(){ const dd=$('composerModelDropdown'); const sel=$('modelSelect'); diff --git a/tests/test_byok_model_dropdown.py b/tests/test_byok_model_dropdown.py index 8b9bdcd5..6a8329aa 100644 --- a/tests/test_byok_model_dropdown.py +++ b/tests/test_byok_model_dropdown.py @@ -173,6 +173,19 @@ class TestLiveModelsProviderNormalization: assert c._resolve_provider_alias(None) is None +def test_shared_searchable_model_picker_helper_has_search_and_custom_controls(): + src = read("static/ui.js") + m = re.search(r"function _mountSearchableModelSelect\(opts=\{\}\)\{.*?\n\}", src, re.DOTALL) + assert m, "_mountSearchableModelSelect helper not found in static/ui.js" + fn = m.group(0) + assert "model-search-input" in fn + assert "model-custom-input" in fn + assert "model-custom-btn" in fn + assert "onModelChange" in fn + assert "selectEl.selectedIndex=-1;" in fn + assert "onModelChange(lastListedValue);" in fn + + # ── api/routes.py — /api/models/live custom_providers fallback ──────────────── class TestLiveModelsCustomProviderFallback: diff --git a/tests/test_issue1362_codex_oauth_onboarding.py b/tests/test_issue1362_codex_oauth_onboarding.py index 6c3358aa..2b9b69bb 100644 --- a/tests/test_issue1362_codex_oauth_onboarding.py +++ b/tests/test_issue1362_codex_oauth_onboarding.py @@ -4,7 +4,10 @@ from __future__ import annotations import json import os +import shutil import stat +import subprocess +import tempfile import threading import time from pathlib import Path @@ -12,6 +15,8 @@ from pathlib import Path import pytest REPO = Path(__file__).resolve().parents[1] +UI_JS = REPO / "static" / "ui.js" +NODE = shutil.which("node") def test_onboarding_codex_oauth_routes_use_post_start_cancel_and_get_poll(): @@ -595,3 +600,362 @@ def test_frontend_has_anthropic_oauth_support(): assert "window.open(" not in js[js.find("startAnthropicOAuth"):] assert "accessToken" not in js[js.find("startAnthropicOAuth"):] assert "refreshToken" not in js[js.find("startAnthropicOAuth"):] + + +def test_onboarding_non_custom_provider_mounts_searchable_model_picker(): + js = (REPO / "static" / "onboarding.js").read_text(encoding="utf-8") + assert "onboardingModelPickerRoot" in js + assert "_mountSearchableModelSelect" in js + assert "choices:_getOnboardingProviderModelChoices()" in js + assert "selectId:'onboardingModelSelect'" in js + assert "customInputId:'onboardingModelInput'" in js + + +def test_onboarding_plain_select_fallback_rehydrates_saved_model(): + """When the searchable picker helper is unavailable, the plain child.value === this._value); + this._selectedIndex = idx; + } + } + + get value() { + if (this.tagName === 'SELECT') { + if (this._selectedIndex >= 0 && this.children[this._selectedIndex]) { + return this.children[this._selectedIndex].value; + } + } + return this._value || ''; + } + + set selectedIndex(index) { + this._selectedIndex = index; + if (this.tagName === 'SELECT') { + this._value = index >= 0 && this.children[index] ? this.children[index].value : ''; + } + } + + get selectedIndex() { + return this._selectedIndex; + } +} + +const document = { + createElement(tagName) { + return new FakeElement(tagName); + }, + getElementById(id) { + return elementsById.get(id) || null; + }, +}; + +global.document = document; +global.window = {}; +global.esc = (value) => String(value); +global.t = (key) => key === 'model_search_placeholder' + ? 'Search models…' + : key === 'model_custom_label' + ? 'Custom model ID' + : key === 'model_custom_placeholder' + ? 'e.g. openai/gpt-5.4' + : key; +global.li = () => '+'; + +eval(extractFunc('_mountSearchableModelSelect')); + +const ONBOARDING = { form: { model: '' } }; +const $ = (id) => document.getElementById(id); +const root = new FakeElement('div'); + +_mountSearchableModelSelect({ + root, + selectId: 'onboardingModelSelect', + customInputId: 'onboardingModelInput', + choices: [ + { id: 'openrouter/alpha', label: 'OpenRouter Alpha' }, + { id: 'openrouter/beta', label: 'OpenRouter Beta' }, + ], + selectedValue: ONBOARDING.form.model, + onModelChange: (value) => { ONBOARDING.form.model = value; }, +}); + +const selectEl = $('onboardingModelSelect'); +const customInput = $('onboardingModelInput'); +const searchInput = root.querySelector('.model-search-input'); + +const initial = { + model: ONBOARDING.form.model, + selectValue: selectEl.value, + selectedIndex: selectEl.selectedIndex, +}; + +searchInput.value = 'beta'; +searchInput.dispatch('input'); +const hiddenStates = selectEl.options.map((option) => ({ + value: option.value, + hidden: option.hidden, +})); + +customInput.value = 'vendor/custom-model'; +customInput.dispatch('input'); +const afterCustom = { + model: ONBOARDING.form.model, + selectedIndex: selectEl.selectedIndex, + selectValue: selectEl.value, +}; + +selectEl.value = 'openrouter/beta'; +selectEl.dispatch('change'); +const afterSelect = { + model: ONBOARDING.form.model, + customValue: customInput.value, + selectValue: selectEl.value, +}; + +customInput.value = 'vendor/final-model'; +customInput.dispatch('input'); +const submitValue = (($('onboardingModelInput') || {}).value || ($('onboardingModelSelect') || {}).value || ONBOARDING.form.model || '').trim(); + +customInput.value = ' '; +customInput.dispatch('input'); +const afterWhitespaceClear = { + model: ONBOARDING.form.model, + selectedIndex: selectEl.selectedIndex, + selectValue: selectEl.value, +}; +const whitespaceSubmitValue = (($('onboardingModelInput') || {}).value || ($('onboardingModelSelect') || {}).value || ONBOARDING.form.model || '').trim(); + +console.log(JSON.stringify({ initial, hiddenStates, afterCustom, afterSelect, submitValue, afterWhitespaceClear, whitespaceSubmitValue, whitespaceCustomValue: customInput.value })); +""" + + with tempfile.NamedTemporaryFile("w", suffix=".cjs", encoding="utf-8", dir=REPO, delete=False) as handle: + handle.write(driver) + script = Path(handle.name) + + try: + result = subprocess.run( + [NODE, str(script), str(UI_JS)], + capture_output=True, + text=True, + timeout=30, + cwd=str(REPO), + ) + finally: + script.unlink(missing_ok=True) + + if result.returncode != 0: + raise RuntimeError(result.stderr) + + out = json.loads(result.stdout.strip()) + assert out["initial"] == { + "model": "openrouter/alpha", + "selectValue": "openrouter/alpha", + "selectedIndex": 1, + } + assert out["hiddenStates"] == [ + {"value": "", "hidden": True}, + {"value": "openrouter/alpha", "hidden": True}, + {"value": "openrouter/beta", "hidden": False}, + ] + assert out["afterCustom"] == { + "model": "vendor/custom-model", + "selectedIndex": -1, + "selectValue": "", + } + assert out["afterSelect"] == { + "model": "openrouter/beta", + "customValue": "", + "selectValue": "openrouter/beta", + } + assert out["submitValue"] == "vendor/final-model" + assert out["afterWhitespaceClear"] == { + "model": "openrouter/beta", + "selectedIndex": 2, + "selectValue": "openrouter/beta", + } + assert out["whitespaceSubmitValue"] == "openrouter/beta" + assert out["whitespaceCustomValue"] == ""