Model picker: search + recent + favorites for large catalogs
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
This commit is contained in:
@@ -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 =
|
||||
'<svg class="mp-star-outline" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'
|
||||
+ '<svg class="mp-star-filled" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></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 =
|
||||
'<span class="mp-empty-title">Search ' + all.length + ' models</span>'
|
||||
+ '<span class="mp-empty-sub">Picks land in Recent · tap ☆ to favorite</span>';
|
||||
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 {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user