Merge pull request #5038 from nesquena/release/stage-5031-onboarding

Release: onboarding searchable model picker (#5031, fixes #4706)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-06-26 21:47:04 -07:00
commit 173eb67715
5 files changed
+512 -2

No files matched your search

+2
View File
@@ -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-`<select>` fallback (when the shared picker is unavailable) correctly preserves a saved/default model instead of resetting to the first option. Thanks @rodboev. (#5031, fixes #4706)
- **French localization is complete again — 40 missing UI strings now have French translations.** The `fr` locale was missing ~40 keys that exist in English (goal status messages, profile management, session metadata, upload/checkpoint messages, "Open in VS Code", and more), so those strings fell back to English for French users. All 40 are now translated, idiomatic-French wording fixes from the contributor are applied (including correcting a machine-mistranslation of "Steer" → "Bœuf"/beef), and the `theme_usage` non-breaking space before the colon is preserved per French typography. Thanks @Pichatu. (#4876)
- **The Settings → Appearance tab-visibility chips are legible in the Graphite light theme again.** The `.tab-visibility-chip` painted hardcoded near-black text (`#1a1a1a`) on `background:var(--accent)`, which resolves to a dark gray (`#303030`) in the Graphite light skin — making the chip labels invisible. The chip now uses the theme-aware token pattern shared by the other chips (`background:var(--accent-bg)` light tint + `border:var(--accent-bg-strong)` + `color:var(--accent-text)`), so labels stay readable across every skin and theme. Thanks @luandnh. (#4888)
+20 -2
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,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
// <select> so a saved/default model that isn't the first option isn't
// silently replaced by option[0] on render.
const modelSel=$('onboardingModelSelect');
if(modelSel && ONBOARDING.form.model) modelSel.value=ONBOARDING.form.model;
}
return;
}
+113
View File
@@ -2443,6 +2443,119 @@ 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;
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');
+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,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 <select>
fallback must still rehydrate ONBOARDING.form.model so a saved/default
model that isn't the first option isn't silently replaced by option[0]."""
js = (REPO / "static" / "onboarding.js").read_text(encoding="utf-8")
# The mount block must have an else branch that restores the plain select value.
mount_idx = js.find("if(modelPickerRoot && typeof _mountSearchableModelSelect")
assert mount_idx != -1
after_mount = js[mount_idx:mount_idx + 900]
assert "}else{" in after_mount or "} else {" in after_mount
assert "const modelSel=$('onboardingModelSelect')" in after_mount
assert "modelSel.value=ONBOARDING.form.model" in after_mount
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 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"] == ""