From a4c2a6990aa60f2f5618196fcc2d6fca8157f567 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Mon, 1 Jun 2026 20:39:34 +0200 Subject: [PATCH] Model picker: search + recent + favorites for large catalogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flat dump of every model in the chat-input picker with a quick-switch. Opening the picker now shows a search box, an auto-tracked Recent list (last 5 picks), and a manual Favorites list instead of every available model crammed into a 280px dropdown. With large catalogs (e.g. OpenRouter's 350+ models) this was unusable as both a quick-switch and a browser. - Recent: each pick is recorded most-recent-first (capped at 5) under a new odysseus-model-recent key, so the next open has it one click away. - Favorites: an inline star on every row toggles favorite state and writes the existing odysseus-model-favorites key, so the sidebar Models section stays in sync. The star toggles only — it never picks the model. - Search filters a flat list across the whole catalog; favorited rows keep their filled star while filtered. - Small catalogs (<=12 models) still list everything in browse mode so tiny installs aren't forced to search for a model. - Touch friendly: stars are always visible (no hover-reveal) and tap targets grow on narrow screens. No changes to sidebar visibility defaults. Closes #399 --- static/js/modelPicker.js | 167 ++++++++++++++++++++++++++++++++------- static/style.css | 86 ++++++++++++++++++++ 2 files changed, 225 insertions(+), 28 deletions(-) diff --git a/static/js/modelPicker.js b/static/js/modelPicker.js index e0cd0b2..41dcfca 100644 --- a/static/js/modelPicker.js +++ b/static/js/modelPicker.js @@ -8,6 +8,54 @@ import { sortModelObjects } from './modelSort.js'; const API_BASE = window.location.origin; +// ── Recent + Favorites persistence ── +// Recent is auto-tracked (last 5 picks, most-recent-first) and lives in its +// own key. Favorites is the SAME key the sidebar Models section uses, so a +// star toggled here shows up there and vice-versa. +const RECENT_KEY = 'odysseus-model-recent'; +const FAVORITES_KEY = 'odysseus-model-favorites'; +const RECENT_MAX = 5; +// Catalogs at or below this size are small enough that hiding everything +// behind search would be a regression — keep listing them in browse mode. +const BROWSE_ALL_LIMIT = 12; + +function _loadList(key) { + try { + const a = JSON.parse(localStorage.getItem(key) || '[]'); + return Array.isArray(a) ? a : []; + } catch { return []; } +} +function _saveList(key, list) { + try { localStorage.setItem(key, JSON.stringify(list)); } catch { /* quota / private mode */ } +} +function _loadRecent() { return _loadList(RECENT_KEY); } +function _pushRecent(mid) { + if (!mid) return; + const next = _loadRecent().filter(x => x !== mid); + next.unshift(mid); + _saveList(RECENT_KEY, next.slice(0, RECENT_MAX)); +} +function _loadFavorites() { return _loadList(FAVORITES_KEY); } +function _toggleFavorite(mid) { + const favs = _loadFavorites(); + const i = favs.indexOf(mid); + if (i >= 0) favs.splice(i, 1); + else favs.push(mid); + _saveList(FAVORITES_KEY, favs); + // Keep the sidebar Models section (same key) in sync if it's mounted. + try { + if (window.modelsModule && typeof window.modelsModule.refreshModels === 'function') { + window.modelsModule.refreshModels(); + } + } catch { /* sidebar not present */ } + return i < 0; // true when now favorited +} + +// Filled star (favorited) + outline star (not) — CSS toggles which shows. +const _STAR_SVG = + '' + + ''; + // ── Shared keyboard nav for model pickers ── function _handlePickerKeydown(e, listEl, itemSelector, closeFn) { if (e.key === 'Escape') { closeFn(); return; } @@ -163,7 +211,7 @@ function _initModelPickerDropdown() { function _populate(filter) { listEl.innerHTML = ''; const all = _getAllModels(); - const q = (filter || '').toLowerCase(); + const q = (filter || '').trim().toLowerCase(); const hasAnyModel = all.length > 0; listEl.classList.toggle('is-empty', !hasAnyModel); menu.classList.toggle('no-models', !hasAnyModel); @@ -171,22 +219,17 @@ function _initModelPickerDropdown() { search.placeholder = hasAnyModel ? 'Search models…' : 'No models connected'; } if (searchRow) { - searchRow.classList.toggle('searching', !!filter); + searchRow.classList.toggle('searching', !!q); } - // Load favorites - const favs = (function() { try { return JSON.parse(localStorage.getItem('odysseus-model-favorites') || '[]'); } catch { return []; } })(); + if (!hasAnyModel) return; // collapsed empty list — nothing to render - // Partition: favorites first, then rest - const favModels = []; - const restModels = []; - all.forEach(m => { - if (q && !m.mid.toLowerCase().includes(q) && !m.display.toLowerCase().includes(q)) return; - if (favs.includes(m.mid)) favModels.push(m); - else restModels.push(m); - }); - sortModelObjects(favModels).forEach(function(m, i) { favModels[i] = m; }); - sortModelObjects(restModels).forEach(function(m, i) { restModels[i] = m; }); + // Unique lookup so Recent/Favorites (stored as bare model IDs) can be + // resolved back to full model objects; drops anything no longer offered. + const byId = new Map(); + all.forEach(m => { if (!byId.has(m.mid)) byId.set(m.mid, m); }); + + const favs = _loadFavorites(); function _addSection(label) { const el = document.createElement('div'); @@ -194,6 +237,12 @@ function _initModelPickerDropdown() { el.textContent = label; listEl.appendChild(el); } + function _addEmpty(text) { + const empty = document.createElement('div'); + empty.className = 'model-switch-empty'; + empty.textContent = text; + listEl.appendChild(empty); + } function _addRow(m) { const row = document.createElement('div'); row.className = 'model-switch-item'; @@ -211,6 +260,7 @@ function _initModelPickerDropdown() { row.appendChild(logoSpan); } const nameSpan = document.createElement('span'); + nameSpan.className = 'mp-model-name'; nameSpan.textContent = m.display; row.appendChild(nameSpan); if (m.stale) { @@ -226,27 +276,84 @@ function _initModelPickerDropdown() { const _epDisplay = m.epName && !m.display.toLowerCase().includes(m.epName.toLowerCase().split('/').pop()) ? m.epName : ''; epSpan.textContent = _epDisplay; row.appendChild(epSpan); + + // Inline favorite star — toggles favorite, never picks the model. + const star = document.createElement('button'); + star.type = 'button'; + star.className = 'mp-fav-star' + (favs.includes(m.mid) ? ' active' : ''); + const _setStarState = (on) => { + star.classList.toggle('active', on); + star.title = on ? 'Remove from favorites' : 'Add to favorites'; + star.setAttribute('aria-label', on ? 'Remove from favorites' : 'Add to favorites'); + star.setAttribute('aria-pressed', on ? 'true' : 'false'); + }; + star.innerHTML = _STAR_SVG; + _setStarState(favs.includes(m.mid)); + star.addEventListener('click', (e) => { + e.stopPropagation(); + const nowFav = _toggleFavorite(m.mid); + _setStarState(nowFav); + // Keep our in-memory copy aligned so a follow-up re-render is correct. + const idx = favs.indexOf(m.mid); + if (nowFav && idx < 0) favs.push(m.mid); + else if (!nowFav && idx >= 0) favs.splice(idx, 1); + if (uiModule && uiModule.showToast) uiModule.showToast(nowFav ? 'Favorited' : 'Unfavorited'); + // In browse mode the Favorites section membership changed — rebuild + // (cheap: Recent + Favorites). In search mode the row stays put, so + // the in-place star update above is enough. + if (!q) { + const st = listEl.scrollTop; + _populate(''); + listEl.scrollTop = st; + } + }); + row.appendChild(star); + row.addEventListener('click', () => _pick(m)); listEl.appendChild(row); } - if (favModels.length > 0) { + // ── Search mode: flat, filtered results across the whole catalog ── + if (q) { + const matches = all.filter(m => + m.mid.toLowerCase().includes(q) || m.display.toLowerCase().includes(q)); + if (matches.length === 0) _addEmpty('No matching models'); + else matches.forEach(_addRow); + return; + } + + // ── Browse mode: Recent (auto) + Favorites (manual). No flat "All" dump. ── + const shown = new Set(); + const recentModels = _loadRecent() + .map(id => byId.get(id)) + .filter(Boolean) + .slice(0, RECENT_MAX); + const favModels = favs.map(id => byId.get(id)).filter(Boolean); + + if (recentModels.length) { + _addSection('Recent'); + recentModels.forEach(m => { shown.add(m.mid); _addRow(m); }); + } + if (favModels.length) { _addSection('Favorites'); - favModels.forEach(_addRow); + favModels.forEach(m => { shown.add(m.mid); _addRow(m); }); } - if (restModels.length > 0) { - if (favModels.length > 0) _addSection('All models'); - restModels.forEach(_addRow); - } - if (listEl.children.length === 0) { - const empty = document.createElement('div'); - empty.className = 'model-switch-empty'; - if (hasAnyModel) { - empty.textContent = 'No matching models'; - } else { - return; + + // Small catalogs: still list everything so users aren't forced to search. + if (all.length <= BROWSE_ALL_LIMIT) { + const rest = all.filter(m => !shown.has(m.mid)); + if (rest.length) { + if (shown.size) _addSection('All models'); + rest.forEach(_addRow); } - listEl.appendChild(empty); + } else if (!recentModels.length && !favModels.length) { + // Large catalog, nothing pinned yet — point them at the search box. + const hint = document.createElement('div'); + hint.className = 'model-switch-empty mp-empty-hint'; + hint.innerHTML = + 'Search ' + all.length + ' models' + + 'Picks land in Recent · tap ☆ to favorite'; + listEl.appendChild(hint); } } @@ -254,6 +361,10 @@ function _initModelPickerDropdown() { const currentSessionId = _deps.getCurrentSessionId(); const _pendingChat = _deps.getPendingChat(); + // Remember this pick so it surfaces under "Recent" next time the picker + // opens — the whole point of quick-switch. + if (m && m.mid) _pushRecent(m.mid); + // Broadcast immediately so listeners (e.g. the tour) can advance without // waiting for the async session-create/PATCH that follows. try { document.dispatchEvent(new CustomEvent('odysseus:model-picked', { detail: m })); } catch {} diff --git a/static/style.css b/static/style.css index 52c7c70..52b5b08 100644 --- a/static/style.css +++ b/static/style.css @@ -2711,6 +2711,92 @@ body.bg-pattern-sparkles { opacity: 0.4; padding: 6px 8px 2px; } + .model-picker-list .mp-section-label:first-child { + padding-top: 2px; + } + /* Model name takes the slack so the endpoint label + star sit on the right. */ + .model-picker-list .model-switch-item .mp-model-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .model-picker-list .model-switch-item .model-switch-ep { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.9em; + opacity: 0.45; + } + /* Keyboard navigation highlight (Arrow keys in the search box). */ + .model-picker-list .model-switch-item.kb-active { + background: color-mix(in srgb, var(--red) 14%, transparent); + } + /* Inline favorite star — always visible (works on touch), filled when on. */ + .model-picker-list .mp-fav-star { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin: -5px -4px -5px 2px; + padding: 0; + border: none; + background: none; + cursor: pointer; + color: color-mix(in srgb, var(--fg) 26%, transparent); + transition: color 0.15s ease, transform 0.12s ease; + -webkit-tap-highlight-color: transparent; + } + .model-picker-list .mp-fav-star:hover { + color: var(--fg); + transform: scale(1.18); + } + .model-picker-list .mp-fav-star:focus-visible { + outline: none; + color: var(--fg); + } + .model-picker-list .mp-fav-star.active { + color: var(--red); + } + .model-picker-list .mp-fav-star.active:hover { + color: var(--red); + opacity: 0.7; + } + .model-picker-list .mp-fav-star .mp-star-filled { display: none; } + .model-picker-list .mp-fav-star.active .mp-star-filled { display: inline-flex; } + .model-picker-list .mp-fav-star.active .mp-star-outline { display: none; } + /* First-run hint when a large catalog has no Recent/Favorites yet. */ + .model-picker-list .mp-empty-hint { + flex-direction: column; + gap: 2px; + padding: 14px 8px; + text-align: center; + } + .model-picker-list .mp-empty-hint .mp-empty-title { + font-size: 1.05em; + color: color-mix(in srgb, var(--fg) 70%, transparent); + } + .model-picker-list .mp-empty-hint .mp-empty-sub { + font-size: 0.92em; + opacity: 0.7; + } + /* Comfortable touch targets on phones / narrow screens. */ + @media (hover: none) and (pointer: coarse), (max-width: 768px) { + .model-picker-list .model-switch-item { + padding-top: 8px; + padding-bottom: 8px; + } + .model-picker-list .mp-fav-star { + width: 30px; + height: 30px; + margin: -7px -4px -7px 2px; + } + } /* Overflow "+" menu */ .overflow-wrapper { position: relative;