From 7aa0649e3edcbd790f4657f8447a3abe1425ab45 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 18 Jun 2026 15:47:11 -0400 Subject: [PATCH 1/4] feat(#4336): add vendor prefix sub-grouping in model picker for OpenRouter and Nous --- static/style.css | 1 + static/ui.js | 94 +++++++++++++++++++++------- tests/test_model_picker_subgroups.py | 37 +++++++++++ 3 files changed, 109 insertions(+), 23 deletions(-) create mode 100644 tests/test_model_picker_subgroups.py diff --git a/static/style.css b/static/style.css index 5de39077..44c15646 100644 --- a/static/style.css +++ b/static/style.css @@ -2519,6 +2519,7 @@ .model-dropdown.open{display:block;} .model-scope-note{position:sticky;top:0;z-index:2;padding:9px 14px;border-bottom:1px solid var(--border);color:var(--text);font-size:11px;font-weight:650;line-height:1.4;background:color-mix(in srgb,var(--surface) 82%,var(--accent-bg));box-shadow:0 1px 0 rgba(0,0,0,.12);} .model-group{padding:8px 14px 4px;font-size:10px;font-weight:700;letter-spacing:.04em;color:var(--muted);text-transform:uppercase;border-top:1px solid var(--border2);margin-top:2px;}.model-group.collapsible{cursor:pointer;user-select:none}.model-group.collapsible:before{content:'\25b6';display:inline-block;font-size:8px;margin-right:5px;transition:transform .15s ease}.model-group.collapsible.open:before{transform:rotate(90deg)}.model-group-body:empty{display:none} +.model-group.sub{padding:4px 14px 2px 22px;font-size:9px;border-top:none;margin-top:0;letter-spacing:.03em;color:var(--muted);background:transparent;}.model-group.sub.collapsible:before{content:'\25b6';display:inline-block;font-size:7px;margin-right:4px;transition:transform .15s ease}.model-group.sub.collapsible.open:before{transform:rotate(90deg)}.model-group-body.sub{padding-left:8px;border-left:1px solid var(--border);} .model-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:3px;align-items:flex-start;} .model-opt:hover{background:rgba(255,255,255,.07);} .model-opt.is-highlighted{background:rgba(255,255,255,.1);outline:1px solid var(--border2);outline-offset:-1px;} diff --git a/static/ui.js b/static/ui.js index 4ba64133..8132be27 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2296,6 +2296,13 @@ function renderModelDropdown(){ } return _groupMeta.get(groupKey); }; + const _vendorPrefix=(rawId)=>{ + const stripped=String(rawId||'').replace(/^@([^:]+:)+/,''); + const slash=stripped.indexOf('/'); + return slash>0?stripped.slice(0,slash):''; + }; + const SUB_GROUP_PROVIDERS=new Set(['openrouter','nous']); + const SUB_GROUP_MIN_MODELS=8; for(const child of Array.from(sel.children)){ if(child.tagName==='OPTGROUP'){ const providerId=child.dataset&&child.dataset.provider?child.dataset.provider:''; @@ -2515,6 +2522,17 @@ function renderModelDropdown(){ const _hit=_modelData.find(m=>m&&!m.endpointErrorOnly&&String(m.value||'')===_selVal); return _hit?_hit.groupKey:null; })(); + const _makeModelRow=(m,sel,configuredIds)=>{ + const row=document.createElement('div'); + row.className='model-opt'+(m.value===sel.value?' active':''); + const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')}`:''; + const _plainGroup=m.group?String(m.group).replace(/\s*\(\d+\s+of\s+\d+\)\s*$/,''):''; + const _underOwnHeading=shouldRenderHeading&&!!(m.groupKey&&_groupWrappers[m.groupKey]); + const providerChip=(_plainGroup&&!_underOwnHeading)?`${esc(_plainGroup)}`:''; + row.innerHTML=`
${esc(m.name)}${badgeHtml}${providerChip}
${esc(m.id)}`; + row.onclick=()=>selectModelFromDropdown(m.value,m.providerId||(m.badge&&m.badge.provider)||null); + return row; + }; const _filterModels=(term)=>{ term=term.trim().toLowerCase(); const hasSearch=!!term; @@ -2669,29 +2687,52 @@ function renderModelDropdown(){ heading.classList.toggle('open',closed); }); } - for(const m of groupRows){ - const row=document.createElement('div'); - row.className='model-opt'+(m.value===sel.value?' active':''); - const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')}`:''; - // The group heading carries the overflow count ("Provider (15 of 30)"); the - // per-row provider chip must show the PLAIN provider label only — otherwise - // the count is stamped redundantly on every row and reads as nonsense after - // "Show all" expands the group (e.g. row 30 still showing "(15 of 30)"). (#3691) - const _plainGroup=m.group?String(m.group).replace(/\s*\(\d+\s+of\s+\d+\)\s*$/,''):''; - // Only show the per-row provider chip when the row is NOT already under its - // own provider heading — i.e. it's a flat/hoisted row appended straight to - // the dropdown (no group wrapper). Repeating the provider name on every row - // beneath its own "PROVIDER" heading is pure visual noise. The chip still - // orients hoisted/configured rows and search results that render flat. - const _underOwnHeading=shouldRenderHeading&&!!(m.groupKey&&_groupWrappers[m.groupKey]); - const providerChip=(_plainGroup&&!_underOwnHeading)?`${esc(_plainGroup)}`:''; - row.innerHTML=`
${esc(m.name)}${badgeHtml}${providerChip}
${esc(m.id)}`; - row.onclick=()=>selectModelFromDropdown(m.value,m.providerId||(m.badge&&m.badge.provider)||null); - if(m.groupKey&&_groupWrappers[m.groupKey]){ - _groupWrappers[m.groupKey].appendChild(row); - }else{ - dd.appendChild(row); + const useSubGroups=( + SUB_GROUP_PROVIDERS.has(meta.providerId) && + groupRows.length>=SUB_GROUP_MIN_MODELS + ); + if(useSubGroups){ + const byPrefix=new Map(); + for(const m of groupRows){ + const pfx=_vendorPrefix(m.value)||'other'; + if(!byPrefix.has(pfx)) byPrefix.set(pfx,[]); + byPrefix.get(pfx).push(m); } + const sorted=[...byPrefix.entries()].sort((a,b)=>{ + if(a[0]==='other') return 1; + if(b[0]==='other') return -1; + return b[1].length-a[1].length; + }); + for(const [pfx,pfxRows] of sorted){ + if(pfxRows.length>=2){ + const subKey=`${groupKey}::${pfx}`; + if(!(subKey in _groupOpenState)) _groupOpenState[subKey]=true; + if(hasSearch) _groupOpenState[subKey]=true; + const subHeading=document.createElement('div'); + subHeading.className='model-group sub collapsible'; + subHeading.dataset.group=subKey; + if(_groupOpenState[subKey]) subHeading.classList.add('open'); + subHeading.textContent=pfx; + const subWrapper=document.createElement('div'); + subWrapper.className='model-group-body sub'; + subWrapper.dataset.group=subKey; + if(!_groupOpenState[subKey]) subWrapper.style.display='none'; + subHeading.addEventListener('click',(e)=>{ + e.stopPropagation(); + const closed=subWrapper.style.display==='none'; + subWrapper.style.display=closed?'':'none'; + _groupOpenState[subKey]=closed; + subHeading.classList.toggle('open',closed); + }); + wrapper.appendChild(subHeading); + wrapper.appendChild(subWrapper); + for(const m of pfxRows) subWrapper.appendChild(_makeModelRow(m,sel,configuredIds)); + } else { + for(const m of pfxRows) wrapper.appendChild(_makeModelRow(m,sel,configuredIds)); + } + } + } else { + for(const m of groupRows) wrapper.appendChild(_makeModelRow(m,sel,configuredIds)); } if(!term&&hiddenCount){ const showAll=document.createElement('div'); @@ -2733,7 +2774,14 @@ function renderModelDropdown(){ }; _si.addEventListener('input',()=>_filterModels(_si.value)); // Keyboard navigation through filtered model rows (#2791). - const _visibleModelRows=()=>Array.from(dd.querySelectorAll('.model-opt,.model-opt-more')).filter(el=>!el.closest('.model-group-body')||el.closest('.model-group-body').style.display!=='none'); + const _visibleModelRows=()=>Array.from(dd.querySelectorAll('.model-opt,.model-opt-more')).filter(el=>{ + let node=el.parentElement; + while(node&&node!==dd){ + if(node.classList.contains('model-group-body')&&node.style.display==='none') return false; + node=node.parentElement; + } + return true; + }); const _activeRowIndex=(rows)=>rows.findIndex(r=>r.classList.contains('is-highlighted')); const _highlightRow=(rows,idx)=>{ for(const r of rows) r.classList.remove('is-highlighted'); diff --git a/tests/test_model_picker_subgroups.py b/tests/test_model_picker_subgroups.py new file mode 100644 index 00000000..94a8bb41 --- /dev/null +++ b/tests/test_model_picker_subgroups.py @@ -0,0 +1,37 @@ +from pathlib import Path +import re + +ROOT=Path(__file__).resolve().parents[1] +UI=(ROOT/"static"/"ui.js").read_text(encoding="utf-8") +CSS=(ROOT/"static"/"style.css").read_text(encoding="utf-8") + +def test_vendor_subgroup_allowlist(): + assert "SUB_GROUP_PROVIDERS" in UI + assert "openrouter" in UI + assert "nous" in UI + +def test_vendor_prefix_strips_routing_prefix(): + # The strip pattern must match the existing normalizer at line 1749 + assert "replace(/^@([^:]+:)+/,'')" in UI + assert re.search(r"indexOf\('/'\)|split\('/'\)", UI) + +def test_subgroup_keys_use_double_colon_separator(): + assert "::" in UI + assert re.search(r"_groupOpenState\[subKey\]", UI) + +def test_single_model_vendor_buckets_stay_flat(): + # pfxRows.length>=2 gate means single-model prefixes render flat + assert re.search(r"\.length\s*>=\s*2", UI) + +def test_visible_rows_walk_all_group_body_ancestors(): + visible_match=re.search(r"const _visibleModelRows=\(\)=>[\s\S]+?\}\);", UI) + assert visible_match, "_visibleModelRows definition not found" + visible=visible_match.group(0) + assert "while(" in visible + assert "parentElement" in visible + assert "model-group-body" in visible + assert "closest('.model-group-body')" not in visible + +def test_nested_group_css_exists(): + assert ".model-group.sub" in CSS + assert ".model-group-body.sub" in CSS From 851cc0e313f251f59adec3fc9f4bbe7665b43284 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 18 Jun 2026 16:15:39 -0400 Subject: [PATCH 2/4] Fix lexical scope errors caught by cross-provider review --- static/ui.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/static/ui.js b/static/ui.js index 8132be27..20a36819 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2522,7 +2522,7 @@ function renderModelDropdown(){ const _hit=_modelData.find(m=>m&&!m.endpointErrorOnly&&String(m.value||'')===_selVal); return _hit?_hit.groupKey:null; })(); - const _makeModelRow=(m,sel,configuredIds)=>{ + const _makeModelRow=(m,sel,shouldRenderHeading)=>{ const row=document.createElement('div'); row.className='model-opt'+(m.value===sel.value?' active':''); const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')}`:''; @@ -2688,6 +2688,7 @@ function renderModelDropdown(){ }); } const useSubGroups=( + wrapper && SUB_GROUP_PROVIDERS.has(meta.providerId) && groupRows.length>=SUB_GROUP_MIN_MODELS ); @@ -2726,13 +2727,13 @@ function renderModelDropdown(){ }); wrapper.appendChild(subHeading); wrapper.appendChild(subWrapper); - for(const m of pfxRows) subWrapper.appendChild(_makeModelRow(m,sel,configuredIds)); + for(const m of pfxRows) subWrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading)); } else { - for(const m of pfxRows) wrapper.appendChild(_makeModelRow(m,sel,configuredIds)); + for(const m of pfxRows) wrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading)); } } - } else { - for(const m of groupRows) wrapper.appendChild(_makeModelRow(m,sel,configuredIds)); + } else if(wrapper){ + for(const m of groupRows) wrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading)); } if(!term&&hiddenCount){ const showAll=document.createElement('div'); From a4058e31e4623bcee38adaf30a0704dcfda61165 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 18 Jun 2026 16:27:07 -0400 Subject: [PATCH 3/4] Move sub-group rendering inside heading scope to fix wrapper ReferenceError --- static/ui.js | 93 ++++++++++++++++++++++++++-------------------------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/static/ui.js b/static/ui.js index 20a36819..ffea4762 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2686,54 +2686,55 @@ function renderModelDropdown(){ if(closed) _forceOpenGroups.add(groupKey); else _forceOpenGroups.delete(groupKey); heading.classList.toggle('open',closed); }); - } - const useSubGroups=( - wrapper && - SUB_GROUP_PROVIDERS.has(meta.providerId) && - groupRows.length>=SUB_GROUP_MIN_MODELS - ); - if(useSubGroups){ - const byPrefix=new Map(); - for(const m of groupRows){ - const pfx=_vendorPrefix(m.value)||'other'; - if(!byPrefix.has(pfx)) byPrefix.set(pfx,[]); - byPrefix.get(pfx).push(m); - } - const sorted=[...byPrefix.entries()].sort((a,b)=>{ - if(a[0]==='other') return 1; - if(b[0]==='other') return -1; - return b[1].length-a[1].length; - }); - for(const [pfx,pfxRows] of sorted){ - if(pfxRows.length>=2){ - const subKey=`${groupKey}::${pfx}`; - if(!(subKey in _groupOpenState)) _groupOpenState[subKey]=true; - if(hasSearch) _groupOpenState[subKey]=true; - const subHeading=document.createElement('div'); - subHeading.className='model-group sub collapsible'; - subHeading.dataset.group=subKey; - if(_groupOpenState[subKey]) subHeading.classList.add('open'); - subHeading.textContent=pfx; - const subWrapper=document.createElement('div'); - subWrapper.className='model-group-body sub'; - subWrapper.dataset.group=subKey; - if(!_groupOpenState[subKey]) subWrapper.style.display='none'; - subHeading.addEventListener('click',(e)=>{ - e.stopPropagation(); - const closed=subWrapper.style.display==='none'; - subWrapper.style.display=closed?'':'none'; - _groupOpenState[subKey]=closed; - subHeading.classList.toggle('open',closed); - }); - wrapper.appendChild(subHeading); - wrapper.appendChild(subWrapper); - for(const m of pfxRows) subWrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading)); - } else { - for(const m of pfxRows) wrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading)); + const useSubGroups=( + SUB_GROUP_PROVIDERS.has(meta.providerId) && + groupRows.length>=SUB_GROUP_MIN_MODELS + ); + if(useSubGroups){ + const byPrefix=new Map(); + for(const m of groupRows){ + const pfx=_vendorPrefix(m.value)||'other'; + if(!byPrefix.has(pfx)) byPrefix.set(pfx,[]); + byPrefix.get(pfx).push(m); } + const sorted=[...byPrefix.entries()].sort((a,b)=>{ + if(a[0]==='other') return 1; + if(b[0]==='other') return -1; + return b[1].length-a[1].length; + }); + for(const [pfx,pfxRows] of sorted){ + if(pfxRows.length>=2){ + const subKey=`${groupKey}::${pfx}`; + if(!(subKey in _groupOpenState)) _groupOpenState[subKey]=true; + if(hasSearch) _groupOpenState[subKey]=true; + const subHeading=document.createElement('div'); + subHeading.className='model-group sub collapsible'; + subHeading.dataset.group=subKey; + if(_groupOpenState[subKey]) subHeading.classList.add('open'); + subHeading.textContent=pfx; + const subWrapper=document.createElement('div'); + subWrapper.className='model-group-body sub'; + subWrapper.dataset.group=subKey; + if(!_groupOpenState[subKey]) subWrapper.style.display='none'; + subHeading.addEventListener('click',(e)=>{ + e.stopPropagation(); + const closed=subWrapper.style.display==='none'; + subWrapper.style.display=closed?'':'none'; + _groupOpenState[subKey]=closed; + subHeading.classList.toggle('open',closed); + }); + wrapper.appendChild(subHeading); + wrapper.appendChild(subWrapper); + for(const m of pfxRows) subWrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading)); + } else { + for(const m of pfxRows) wrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading)); + } + } + } else { + for(const m of groupRows) wrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading)); } - } else if(wrapper){ - for(const m of groupRows) wrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading)); + } else { + for(const m of groupRows) dd.appendChild(_makeModelRow(m,sel,shouldRenderHeading)); } if(!term&&hiddenCount){ const showAll=document.createElement('div'); From eeee30fc6eb63175615d5c46af16d80c5c29d295 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 19 Jun 2026 01:41:09 +0000 Subject: [PATCH 4/4] =?UTF-8?q?release:=20v0.51.504=20=E2=80=94=20group=20?= =?UTF-8?q?OpenRouter/Nous=20models=20by=20vendor=20in=20model=20picker=20?= =?UTF-8?q?(#4440)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser-stress-tested end-to-end (collapse parent/sub, search filter+restore, select-from-subgroup, flat single-vendor rows) — Nathan approved from screenshots. Co-authored-by: rodboev Closes #4440 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbbe736b..83f50fd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.504] — 2026-06-18 — Release RO (group OpenRouter / Nous models by vendor in the model picker) + +### Added + +- **The model picker now groups OpenRouter and Nous models into collapsible vendor sub-groups (#4440).** When one of those providers exposes a large catalog (8+ models), the dropdown splits it by vendor prefix — `openai`, `anthropic`, `google`, etc. — under collapsible sub-headings, sorted by model count, with single-model vendors left as flat rows (no one-item headings). This tames OpenRouter's very large flat model list without affecting smaller providers, which render unchanged. Sub-groups expand automatically while searching. Thanks @rodboev. + ## [v0.51.503] — 2026-06-18 — Release RN (process-wakeup turns no longer render as user messages) ### Fixed