@@ -196,11 +196,24 @@ def get_dashboard_data(db_path=DB_PATH):
196196 #filter-bar { background: var(--card); border-bottom: 1px solid var(--border); padding: 10px 24px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
197197 .filter-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); white-space: nowrap; }
198198 .filter-sep { width: 1px; height: 22px; background: var(--border); flex-shrink: 0; }
199- #model-checkboxes { display: flex; flex-wrap: wrap; gap: 6px; }
200- .model-cb-label { display: flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; border: 1px solid var(--border); cursor: pointer; font-size: 12px; color: var(--muted); transition: border-color 0.15s, color 0.15s, background 0.15s; user-select: none; }
201- .model-cb-label:hover { border-color: var(--accent); color: var(--text); }
202- .model-cb-label.checked { background: var(--selected); border-color: var(--accent); color: var(--text); }
199+ /* Model multi-select: a compact trigger in the bar that opens a grouped panel. */
200+ .model-select { position: relative; flex-shrink: 0; }
201+ .model-trigger { display: flex; align-items: center; gap: 8px; min-width: 170px; max-width: 320px; padding: 5px 10px; background: var(--card); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 12px; cursor: pointer; transition: border-color 0.15s; }
202+ .model-trigger:hover, .model-trigger.open { border-color: var(--accent); }
203+ #model-trigger-label { flex: 1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
204+ .model-caret { color: var(--muted); font-size: 10px; flex-shrink: 0; transition: transform 0.15s; }
205+ .model-trigger.open .model-caret { transform: rotate(180deg); }
206+ .model-panel { position: absolute; top: calc(100% + 6px); left: 0; z-index: 50; min-width: 250px; max-width: 340px; max-height: 360px; overflow-y: auto; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); }
207+ .model-panel[hidden] { display: none; }
208+ .model-panel-actions { display: flex; gap: 6px; padding-bottom: 8px; margin-bottom: 4px; border-bottom: 1px solid var(--border); }
209+ .model-group-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); padding: 8px 8px 4px; }
210+ .model-cb-label { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 12px; color: var(--muted); transition: background 0.12s, color 0.12s; user-select: none; }
211+ .model-cb-label:hover { background: var(--raised); color: var(--text); }
212+ .model-cb-label.checked { color: var(--text); }
203213 .model-cb-label input { display: none; }
214+ .model-cb-box { width: 15px; height: 15px; flex-shrink: 0; border-radius: 4px; border: 1px solid var(--border); display: flex; align-items: center; justify-content: center; font-size: 10px; line-height: 1; color: transparent; transition: background 0.12s, border-color 0.12s; }
215+ .model-cb-label.checked .model-cb-box { background: var(--accent); border-color: var(--accent); color: #fff; }
216+ .model-cb-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
204217 .filter-btn { padding: 3px 10px; border-radius: 4px; border: 1px solid var(--border); background: transparent; color: var(--muted); font-size: 11px; cursor: pointer; white-space: nowrap; }
205218 .filter-btn:hover { border-color: var(--accent); color: var(--text); }
206219 .range-group { display: flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; flex-shrink: 0; }
@@ -287,9 +300,19 @@ def get_dashboard_data(db_path=DB_PATH):
287300
288301<div id="filter-bar">
289302 <div class="filter-label">Models</div>
290- <div id="model-checkboxes"></div>
291- <button class="filter-btn" onclick="selectAllModels()">All</button>
292- <button class="filter-btn" onclick="clearAllModels()">None</button>
303+ <div class="model-select" id="model-select">
304+ <button class="model-trigger" id="model-trigger" aria-haspopup="true" aria-expanded="false" onclick="toggleModelPanel(event)">
305+ <span id="model-trigger-label">All models</span>
306+ <span class="model-caret">▾</span>
307+ </button>
308+ <div class="model-panel" id="model-panel" hidden>
309+ <div class="model-panel-actions">
310+ <button class="filter-btn" onclick="selectAllModels()">All</button>
311+ <button class="filter-btn" onclick="clearAllModels()">None</button>
312+ </div>
313+ <div id="model-checkboxes"></div>
314+ </div>
315+ </div>
293316 <div class="filter-sep"></div>
294317 <div class="filter-label">Range</div>
295318 <div class="range-group">
@@ -426,6 +449,7 @@ def get_dashboard_data(db_path=DB_PATH):
426449// ── State ──────────────────────────────────────────────────────────────────
427450let rawData = null;
428451let selectedModels = new Set();
452+ let allModelsList = [];
429453let selectedRange = '30d';
430454let charts = {};
431455let sessionSortCol = 'last';
@@ -707,6 +731,35 @@ def get_dashboard_data(db_path=DB_PATH):
707731 return 4;
708732}
709733
734+ function sortedModels(models) {
735+ return [...models].sort((a, b) => {
736+ const pa = modelPriority(a), pb = modelPriority(b);
737+ return pa !== pb ? pa - pb : a.localeCompare(b);
738+ });
739+ }
740+
741+ // Compact display name for the collapsed trigger, e.g. "claude-opus-4-8" ->
742+ // "Opus 4.8", "claude-fable-5" -> "Fable 5". Non-Anthropic ids fall back to the
743+ // basename with any provider prefix and trailing date suffix stripped.
744+ function shortModelName(m) {
745+ const ml = m.toLowerCase();
746+ let family = null;
747+ if (ml.includes('fable')) family = 'Fable';
748+ else if (ml.includes('mythos')) family = 'Mythos';
749+ else if (ml.includes('opus')) family = 'Opus';
750+ else if (ml.includes('sonnet')) family = 'Sonnet';
751+ else if (ml.includes('haiku')) family = 'Haiku';
752+ if (family) {
753+ const two = m.match(/(\d+)[._-](\d+)/);
754+ if (two) return family + ' ' + two[1] + '.' + two[2];
755+ const one = m.match(/(\d+)/);
756+ return one ? family + ' ' + one[1] : family;
757+ }
758+ let base = m.split('/').pop().split(':')[0];
759+ base = base.replace(/[-_]?\d{6,}.*$/, '');
760+ return base || m;
761+ }
762+
710763function readURLModels(allModels) {
711764 const param = new URLSearchParams(window.location.search).get('models');
712765 if (!param) {
@@ -727,25 +780,94 @@ def get_dashboard_data(db_path=DB_PATH):
727780}
728781
729782function buildFilterUI(allModels) {
730- const sorted = [...allModels].sort((a, b) => {
731- const pa = modelPriority(a), pb = modelPriority(b);
732- return pa !== pb ? pa - pb : a.localeCompare(b);
733- });
783+ allModelsList = [...allModels];
734784 selectedModels = readURLModels(allModels);
735- const container = document.getElementById('model-checkboxes');
736- container.innerHTML = sorted.map(m => {
785+ const sorted = sortedModels(allModels);
786+ const anthropic = sorted.filter(m => isBillable(m));
787+ const other = sorted.filter(m => !isBillable(m));
788+ const rowHTML = m => {
737789 const checked = selectedModels.has(m);
738- return `<label class="model-cb-label ${checked ? 'checked' : ''}" data-model="${esc(m)}">
790+ return `<label class="model-cb-label ${checked ? 'checked' : ''}" data-model="${esc(m)}" title="${esc(m)}" >
739791 <input type="checkbox" value="${esc(m)}" ${checked ? 'checked' : ''} onchange="onModelToggle(this)">
740- ${esc(m)}
792+ <span class="model-cb-box">✓</span>
793+ <span class="model-cb-text">${esc(m)}</span>
741794 </label>`;
742- }).join('');
743- }
795+ };
796+ let html = '';
797+ // Only show a group heading when both groups are present — a single-group
798+ // list doesn't need a label.
799+ const labelled = anthropic.length && other.length;
800+ if (anthropic.length) {
801+ if (labelled) html += '<div class="model-group-label">Anthropic</div>';
802+ html += anthropic.map(rowHTML).join('');
803+ }
804+ if (other.length) {
805+ if (labelled) html += '<div class="model-group-label">Other providers</div>';
806+ html += other.map(rowHTML).join('');
807+ }
808+ document.getElementById('model-checkboxes').innerHTML = html;
809+ updateModelTriggerLabel();
810+ }
811+
812+ // Collapsed trigger text, in priority order:
813+ // "All models" — everything selected
814+ // "No models" — nothing selected
815+ // "All Anthropic" — every Anthropic model (opus/sonnet/haiku/mythos/fable)
816+ // selected and no other provider; "+N" if some others too
817+ // "Fable 5, Opus 4.7 +5" — otherwise, first two names + overflow count
818+ function updateModelTriggerLabel() {
819+ const labelEl = document.getElementById('model-trigger-label');
820+ if (!labelEl) return;
821+ const n = selectedModels.size;
822+ if (n === 0) { labelEl.textContent = 'No models'; return; }
823+ if (n === allModelsList.length) { labelEl.textContent = 'All models'; return; }
824+ const anthropic = allModelsList.filter(m => isBillable(m));
825+ const others = allModelsList.filter(m => !isBillable(m));
826+ if (anthropic.length && anthropic.every(m => selectedModels.has(m))) {
827+ // n < total (handled above), so when others exist at least one is unselected.
828+ const otherSel = others.filter(m => selectedModels.has(m)).length;
829+ labelEl.textContent = otherSel ? 'All Anthropic +' + otherSel : 'All Anthropic';
830+ return;
831+ }
832+ const chosen = sortedModels(allModelsList).filter(m => selectedModels.has(m));
833+ const shown = chosen.slice(0, 2).map(shortModelName);
834+ const extra = chosen.length - shown.length;
835+ labelEl.textContent = shown.join(', ') + (extra > 0 ? ' +' + extra : '');
836+ }
837+
838+ function toggleModelPanel(event) {
839+ if (event) event.stopPropagation();
840+ const panel = document.getElementById('model-panel');
841+ const trigger = document.getElementById('model-trigger');
842+ const open = panel.hidden;
843+ panel.hidden = !open;
844+ trigger.classList.toggle('open', open);
845+ trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
846+ }
847+
848+ function closeModelPanel() {
849+ const panel = document.getElementById('model-panel');
850+ if (!panel || panel.hidden) return;
851+ panel.hidden = true;
852+ const trigger = document.getElementById('model-trigger');
853+ trigger.classList.remove('open');
854+ trigger.setAttribute('aria-expanded', 'false');
855+ }
856+
857+ // Close the panel on outside click or Escape. Clicks inside #model-select
858+ // (including the checkboxes and All/None) keep it open so multiple models can
859+ // be toggled in one pass.
860+ document.addEventListener('click', (e) => {
861+ const sel = document.getElementById('model-select');
862+ if (sel && !sel.contains(e.target)) closeModelPanel();
863+ });
864+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModelPanel(); });
744865
745866function onModelToggle(cb) {
746867 const label = cb.closest('label');
747868 if (cb.checked) { selectedModels.add(cb.value); label.classList.add('checked'); }
748869 else { selectedModels.delete(cb.value); label.classList.remove('checked'); }
870+ updateModelTriggerLabel();
749871 updateURL();
750872 applyFilter();
751873}
@@ -754,14 +876,14 @@ def get_dashboard_data(db_path=DB_PATH):
754876 document.querySelectorAll('#model-checkboxes input').forEach(cb => {
755877 cb.checked = true; selectedModels.add(cb.value); cb.closest('label').classList.add('checked');
756878 });
757- updateURL(); applyFilter();
879+ updateModelTriggerLabel(); updateURL(); applyFilter();
758880}
759881
760882function clearAllModels() {
761883 document.querySelectorAll('#model-checkboxes input').forEach(cb => {
762884 cb.checked = false; selectedModels.delete(cb.value); cb.closest('label').classList.remove('checked');
763885 });
764- updateURL(); applyFilter();
886+ updateModelTriggerLabel(); updateURL(); applyFilter();
765887}
766888
767889// ── URL persistence ────────────────────────────────────────────────────────
0 commit comments