fix(#4706): reuse the searchable model picker in onboarding

This commit is contained in:
Rod Boev
2026-06-26 22:51:12 -04:00
committed by nesquena-hermes
parent 12b269f94c
commit d0eb9b57df
4 changed files with 478 additions and 0 deletions
+14
View File
@@ -209,6 +209,9 @@ function _renderOnboardingModelField(){
if(ONBOARDING.form.provider==='custom'){
return `<label class="onboarding-field"><span>${t('onboarding_model_label')}</span><input id="onboardingModelInput" value="${esc(_getOnboardingSelectedModel())}" placeholder="${t('onboarding_custom_model_placeholder')}" oninput="ONBOARDING.form.model=this.value"></label><p class="onboarding-copy">${t('onboarding_custom_model_help')}</p>`;
}
if(typeof _mountSearchableModelSelect==='function'){
return `<div class="onboarding-field onboarding-model-field"><span>${t('onboarding_model_label')}</span><div id="onboardingModelPickerRoot"></div></div><p class="onboarding-copy">${t('onboarding_workspace_help')}</p>`;
}
const options=choices.map(m=>`<option value="${esc(m.id)}">${esc(m.label)}</option>`).join('');
return `<label class="onboarding-field"><span>${t('onboarding_model_label')}</span><select id="onboardingModelSelect" onchange="ONBOARDING.form.model=this.value">${options}</select></label><p class="onboarding-copy">${t('onboarding_workspace_help')}</p>`;
}
@@ -356,6 +359,17 @@ function _renderOnboardingBody(){
${_renderOnboardingModelField()}`;
const wsSel=$('onboardingWorkspaceSelect');
if(wsSel && ONBOARDING.form.workspace) wsSel.value=ONBOARDING.form.workspace;
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(); },
});
}
const modelSel=$('onboardingModelSelect');
if(modelSel && ONBOARDING.form.model) modelSel.value=ONBOARDING.form.model;
return;
+111
View File
@@ -2443,6 +2443,117 @@ function _appendOverflowOptionsToGroup(group, extraModels){
return appended;
}
function _mountSearchableModelSelect(opts={}){
const root=opts.root;
if(!root) return null;
const choices=Array.isArray(opts.choices)
? opts.choices
.map(choice=>choice&&choice.id?{id:String(choice.id),label:String(choice.label||choice.id)}:null)
.filter(Boolean)
: [];
const selectedValue=String(opts.selectedValue||'');
const onModelChange=typeof opts.onModelChange==='function' ? opts.onModelChange : ()=>{};
const selectId=opts.selectId||'';
const customInputId=opts.customInputId||'';
const listedChoiceIds=new Set(choices.map(choice=>choice.id));
const listedSelection=listedChoiceIds.has(selectedValue) ? selectedValue : '';
const customSelection=listedSelection ? '' : selectedValue;
let lastListedValue=listedSelection||(choices[0]?choices[0].id:'');
root.innerHTML=
`<div class="model-search-row">`+
`<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>`+
`</div>`+
`<select ${selectId?`id="${esc(selectId)}"`:''}></select>`+
`<div class="model-group model-custom-sep">${esc(t('model_custom_label')||'Custom model ID')}</div>`+
`<div class="model-custom-row">`+
`<input ${customInputId?`id="${esc(customInputId)}"`:''} class="model-custom-input" type="text" placeholder="${esc(t('model_custom_placeholder')||'e.g. openai/gpt-5.4')}" spellcheck="false" autocomplete="off">`+
`<button class="model-custom-btn" title="Use this model">${li('plus',12)}</button>`+
`</div>`;
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;
}
applyCustomSelection();
});
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');
+13
View File
@@ -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:
@@ -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,338 @@ 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_custom_provider_text_input_branch_stays_intact():
js = (REPO / "static" / "onboarding.js").read_text(encoding="utf-8")
assert "ONBOARDING.form.provider==='custom'" in js
assert 'id="onboardingModelInput"' in js
assert "onboarding_custom_model_placeholder" in js
assert "onboarding_custom_model_help" in js
@pytest.mark.skipif(NODE is None, reason="node is required")
def test_onboarding_searchable_picker_runtime_and_submit_fallback():
driver = r"""
const fs = require('fs');
const src = fs.readFileSync(process.argv[2], 'utf8');
function extractFunc(name) {
const re = new RegExp('function\\s+' + name + '\\s*\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
const parenStart = src.indexOf('(', start);
let parenDepth = 0;
let inString = null;
let escaped = false;
let bodyStart = -1;
for (let i = parenStart; i < src.length; i++) {
const ch = src[i];
if (inString) {
if (escaped) escaped = false;
else if (ch === '\\') escaped = true;
else if (ch === inString) inString = null;
continue;
}
if (ch === "'" || ch === '"' || ch === '`') {
inString = ch;
continue;
}
if (ch === '(') parenDepth++;
else if (ch === ')') {
parenDepth--;
if (parenDepth === 0) {
bodyStart = src.indexOf('{', i);
break;
}
}
}
if (bodyStart < 0) throw new Error(name + ' body not found');
let depth = 0;
inString = null;
escaped = false;
for (let i = bodyStart; i < src.length; i++) {
const ch = src[i];
if (inString) {
if (escaped) escaped = false;
else if (ch === '\\') escaped = true;
else if (ch === inString) inString = null;
continue;
}
if (ch === "'" || ch === '"' || ch === '`') {
inString = ch;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) return src.slice(start, i + 1);
}
}
throw new Error(name + ' brace scan failed');
}
const elementsById = new Map();
class FakeElement {
constructor(tagName) {
this.tagName = tagName.toUpperCase();
this.children = [];
this.parentNode = null;
this.className = '';
this.id = '';
this.hidden = false;
this.disabled = false;
this.textContent = '';
this._value = '';
this._selectedIndex = -1;
this._listeners = {};
}
appendChild(child) {
child.parentNode = this;
this.children.push(child);
if (child.id) elementsById.set(child.id, child);
if (this.tagName === 'SELECT' && child.tagName === 'OPTION' && this._selectedIndex < 0 && !child.disabled) {
this.selectedIndex = this.children.length - 1;
}
return child;
}
addEventListener(type, handler) {
this._listeners[type] = handler;
}
dispatch(type, extra = {}) {
const handler = this._listeners[type];
if (!handler) return;
handler({
type,
target: this,
key: extra.key,
preventDefault() {},
});
}
focus() {}
querySelector(selector) {
const queue = [...this.children];
while (queue.length) {
const node = queue.shift();
if (selector.startsWith('.')) {
const cls = selector.slice(1);
if ((node.className || '').split(/\s+/).includes(cls)) return node;
} else if (selector.startsWith('#')) {
if (node.id === selector.slice(1)) return node;
} else if (node.tagName === selector.toUpperCase()) {
return node;
}
queue.push(...node.children);
}
return null;
}
set innerHTML(html) {
this.children = [];
const selectId = (html.match(/<select id="([^"]+)"/) || [])[1] || '';
const customInputId = (html.match(/<input id="([^"]+)" class="model-custom-input"/) || [])[1] || '';
const searchRow = new FakeElement('div');
searchRow.className = 'model-search-row';
const searchInput = new FakeElement('input');
searchInput.className = 'model-search-input';
const clearBtn = new FakeElement('button');
clearBtn.className = 'model-search-clear';
searchRow.appendChild(searchInput);
searchRow.appendChild(clearBtn);
const selectEl = new FakeElement('select');
selectEl.id = selectId;
const sep = new FakeElement('div');
sep.className = 'model-group model-custom-sep';
const customRow = new FakeElement('div');
customRow.className = 'model-custom-row';
const customInput = new FakeElement('input');
customInput.className = 'model-custom-input';
customInput.id = customInputId;
const customBtn = new FakeElement('button');
customBtn.className = 'model-custom-btn';
customRow.appendChild(customInput);
customRow.appendChild(customBtn);
this.appendChild(searchRow);
this.appendChild(selectEl);
this.appendChild(sep);
this.appendChild(customRow);
}
get options() {
return this.children;
}
set value(value) {
this._value = String(value);
if (this.tagName === 'SELECT') {
const idx = this.children.findIndex((child) => 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 whitespaceSubmitValue = (($('onboardingModelInput') || {}).value || ($('onboardingModelSelect') || {}).value || ONBOARDING.form.model || '').trim();
console.log(JSON.stringify({ initial, hiddenStates, afterCustom, afterSelect, submitValue, 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["whitespaceSubmitValue"] == "openrouter/beta"
assert out["whitespaceCustomValue"] == ""