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;