feat(ui): searchable popup pickers for PR scope and model visibility
Build & push image / build-and-push (push) Successful in 14s
CI / test (push) Successful in 10m50s

Replace the cramped PR multi-select with a modal: every repo#pr as a
checkbox (with model coverage), a search box, and all/none that apply to
the search results. The model hider moves to the same popup style — the
per-row × and the hidden-chips bar are gone; both pickers live as
buttons in the filter row showing their current state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 23:04:40 -04:00
parent 1af115fdf1
commit 7fce78a664
2 changed files with 125 additions and 71 deletions
+115 -62
View File
@@ -21,8 +21,19 @@
input, select, button { background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:6px 8px; font:inherit; }
input[type=number] { width:64px; }
input[type=date] { width:140px; }
select[multiple] { min-width:200px; }
input.search { width:220px; }
#modalback { position:fixed; inset:0; background:rgba(0,0,0,.55); display:none; z-index:40; }
#modal { position:fixed; top:12vh; left:50%; transform:translateX(-50%); width:min(560px,92vw); max-height:72vh;
background:var(--panel); border:1px solid var(--line); border-radius:10px; display:none; flex-direction:column;
z-index:41; box-shadow:0 12px 40px rgba(0,0,0,.5); }
#modal .mhead { display:flex; gap:8px; align-items:center; padding:10px 12px; border-bottom:1px solid var(--line); }
#modal .mhead b { margin-right:auto; white-space:nowrap; }
#modal .mhead input { flex:1; min-width:80px; width:auto; }
#mlist { overflow:auto; padding:6px 0; }
#mlist label.item { display:flex; gap:10px; align-items:center; padding:6px 14px; cursor:pointer; font-size:13px; }
#mlist label.item:hover { background:#1d212b; }
#mlist .note { margin-left:auto; color:var(--mut); font-size:12px; }
#modal .mfoot { padding:8px 12px; border-top:1px solid var(--line); color:var(--mut); font-size:12px; }
button { cursor:pointer; }
button.primary { background:var(--acc); color:#0c0e12; border-color:var(--acc); font-weight:600; }
button.link { background:none; border:none; color:var(--acc); padding:0; text-decoration:underline; }
@@ -65,7 +76,8 @@
<div class="f"><label>from</label><input type="date" id="from"></div>
<div class="f"><label>to</label><input type="date" id="to"></div>
<div class="f"><label>repo</label><select id="repo"></select></div>
<div class="f"><label>PRs (⌘/ctrl-click for several)</label><select id="pr" multiple size="4" title="Limit the whole comparison to these PRs — every model is scored only on runs/findings from them. The option label shows how many models ran each PR."></select></div>
<div class="f"><label>PRs</label><button id="prbtn" onclick="openModal('pr')" title="Limit the whole comparison to a set of PRs — every model is scored only on runs/findings from them.">all PRs ▾</button></div>
<div class="f"><label>models shown</label><button id="modelsbtn" onclick="openModal('models')" title="Hide models from the scoreboard entirely (e.g. retired ones). Persists in this browser.">all ▾</button></div>
<div class="f"><label>provider</label><select id="provider"></select></div>
<div class="f"><label>model</label><select id="model"></select></div>
<div class="f"><label>lens</label><select id="lens"></select></div>
@@ -90,7 +102,6 @@
<div class="panel">
<div id="summary" class="small mut" style="margin-bottom:8px"></div>
<div id="hidden" class="small mut" style="margin-bottom:8px;display:none"></div>
<table id="models">
<thead><tr id="mhead"></tr></thead>
<tbody id="mbody"></tbody>
@@ -112,6 +123,21 @@
</div>
</main>
<!-- shared picker modal: PR scope + model visibility (outside <main> so its
search box doesn't trigger the global re-render listener) -->
<div id="modalback" onclick="closeModal()"></div>
<div id="modal">
<div class="mhead">
<b id="mtitle"></b>
<input id="msearch" placeholder="filter…" oninput="fillModal()">
<button onclick="modalSetAll(true)">all</button>
<button onclick="modalSetAll(false)">none</button>
<button class="primary" onclick="closeModal()">done</button>
</div>
<div id="mlist"></div>
<div class="mfoot" id="mfoot"></div>
</div>
<script>
const SEVS = ["trivial","small","medium","high","critical"];
const SEVCOLOR = { trivial:"#3b4252", small:"#2e4d3a", medium:"#4d4a2e", high:"#5a3b2e", critical:"#5a2e3a" };
@@ -125,9 +151,82 @@ let sortKey = "ptsPerMin", sortAsc = false, selModel = null;
function loadHidden(){ try { return new Set(JSON.parse(localStorage.getItem("grt-hidden") || "[]")); } catch { return new Set(); } }
let HIDDEN = loadHidden();
function saveHidden(){ localStorage.setItem("grt-hidden", JSON.stringify([...HIDDEN].sort())); }
function hideModel(m){ HIDDEN.add(m); if (selModel===m) selModel=null; saveHidden(); render(); }
function showModel(m){ HIDDEN.delete(m); saveHidden(); render(); }
function showAllModels(){ HIDDEN.clear(); saveHidden(); render(); }
// PRs the comparison is scoped to (repo#pr keys). Empty = every PR counts.
let SELPRS = new Set();
// ---- picker modal (shared by the PR scope and the model hider) ----
let modalKind = null;
function openModal(kind){
modalKind = kind;
document.getElementById("mtitle").textContent = kind==="pr" ? "compare only these PRs" : "models shown";
document.getElementById("msearch").value = "";
document.getElementById("modalback").style.display = "block";
document.getElementById("modal").style.display = "flex";
fillModal();
document.getElementById("msearch").focus();
}
function closeModal(){
modalKind = null;
document.getElementById("modalback").style.display = "none";
document.getElementById("modal").style.display = "none";
}
// The full item list, rebuilt from the raw data on every open/refresh so it
// always has every PR / model. checked = in the comparison.
function modalItems(){
const allModels = uniq([...RUNS.map(r=>r.model), ...ROWS.map(r=>r.model)]);
if (modalKind === "pr"){
const byPR = new Map();
for (const r of [...RUNS, ...ROWS]){
const k = prKey(r);
if (!byPR.has(k)) byPR.set(k, new Set());
if (r.model) byPR.get(k).add(r.model);
}
return [...byPR.keys()].sort((a,b)=>{
const [ra,pa] = splitPR(a), [rb,pb] = splitPR(b);
return ra===rb ? pb-pa : ra.localeCompare(rb); // newest PR first within a repo
}).map(k => ({ value:k, label:k, note:`${byPR.get(k).size}/${allModels.length} models`, checked:SELPRS.has(k) }));
}
return allModels.map(m => ({ value:m, label:m, note:HIDDEN.has(m)?"hidden":"", checked:!HIDDEN.has(m) }));
}
function visibleModalItems(){
const q = document.getElementById("msearch").value.trim().toLowerCase();
return modalItems().filter(it => !q || it.label.toLowerCase().includes(q));
}
function fillModal(){
const list = document.getElementById("mlist"); list.innerHTML = "";
for (const it of visibleModalItems()){
const lab = document.createElement("label"); lab.className = "item";
const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = it.checked;
cb.onchange = ()=> modalToggle(it.value, cb.checked);
const name = document.createElement("span"); name.textContent = it.label;
const note = document.createElement("span"); note.className = "note"; note.textContent = it.note;
lab.append(cb, name, note);
list.appendChild(lab);
}
document.getElementById("mfoot").textContent = modalKind==="pr"
? (SELPRS.size ? `${SELPRS.size} PR${SELPRS.size===1?"":"s"} selected — only these are compared` : "none selected — every PR counts")
: (HIDDEN.size ? `${HIDDEN.size} hidden — excluded from the scoreboard (persists in this browser)` : "all models shown");
}
function modalToggle(v, on){
if (modalKind === "pr"){ if (on) SELPRS.add(v); else SELPRS.delete(v); }
else {
if (on) HIDDEN.delete(v); else { HIDDEN.add(v); if (selModel===v) selModel=null; }
saveHidden();
}
fillModal(); render();
}
// all/none apply to the search-filtered items, so you can e.g. type a repo
// name and select all its PRs at once.
function modalSetAll(on){
for (const it of visibleModalItems()){
if (modalKind === "pr"){ if (on) SELPRS.add(it.value); else SELPRS.delete(it.value); }
else if (on) HIDDEN.delete(it.value);
else { HIDDEN.add(it.value); if (selModel===it.value) selModel=null; }
}
if (modalKind !== "pr") saveHidden();
fillModal(); render();
}
function token(){
const q = new URL(location.href).searchParams.get("token");
@@ -170,34 +269,8 @@ function opt(sel, vals, label){
if (vals.includes(cur)) sel.value = cur;
}
function prKey(o){ return o.repo + "#" + o.pr; }
// The PR facet lists every repo#pr with how many models ran it, so it's obvious
// which PRs are a fair head-to-head (e.g. "steve/x#12 · 5/5 models").
function buildPRFacet(){
const allModels = uniq(RUNS.map(r=>r.model));
const byPR = new Map();
for (const r of [...RUNS, ...ROWS]){
const k = prKey(r);
if (!byPR.has(k)) byPR.set(k, new Set());
if (r.model) byPR.get(k).add(r.model);
}
const sel = document.getElementById("pr");
const cur = new Set([...sel.selectedOptions].map(o=>o.value));
sel.innerHTML = "";
const keys = [...byPR.keys()].sort((a,b)=>{
const [ra,pa] = splitPR(a), [rb,pb] = splitPR(b);
return ra===rb ? pb-pa : ra.localeCompare(rb); // newest PR first within a repo
});
for (const k of keys){
const o = document.createElement("option");
o.value = k;
o.textContent = `${k} · ${byPR.get(k).size}/${allModels.length} models`;
if (cur.has(k)) o.selected = true;
sel.appendChild(o);
}
}
function splitPR(k){ const i = k.lastIndexOf("#"); return [k.slice(0,i), +k.slice(i+1)]; }
function buildFacets(){
buildPRFacet();
opt(document.getElementById("repo"), uniq([...RUNS.map(r=>r.repo), ...ROWS.map(r=>r.repo)]), "all repos");
opt(document.getElementById("provider"), uniq([...RUNS.map(r=>r.provider), ...ROWS.map(r=>r.provider)]), "all providers");
opt(document.getElementById("model"), uniq([...RUNS.map(r=>r.model), ...ROWS.map(r=>r.model)]), "all models");
@@ -226,7 +299,7 @@ function filters(){
return {
from: document.getElementById("from").value,
to: document.getElementById("to").value,
prs: new Set([...document.getElementById("pr").selectedOptions].map(o=>o.value)),
prs: SELPRS,
repo: document.getElementById("repo").value,
provider: document.getElementById("provider").value,
model: document.getElementById("model").value,
@@ -236,7 +309,7 @@ function filters(){
};
}
function dateOK(ts, f){ const d = (ts||"").slice(0,10); return (!f.from || d >= f.from) && (!f.to || d <= f.to); }
// prOK gates a run/row on the PR multi-select: no selection = every PR counts,
// prOK gates a run/row on the PR picker: no selection = every PR counts,
// regardless of which models ran it.
function prOK(o, f){ return !f.prs.size || f.prs.has(prKey(o)); }
// run-level filters only (date/repo/provider/model/pr) — severity/lens/search are finding-level.
@@ -345,16 +418,7 @@ function render(){
for (const col of COLS){
const td = document.createElement("td"); if (col.l) td.className="l";
const v = m[col.k];
if (col.k==="model"){
// model name + a hide control (× pill) — injection-safe via JS handler.
td.textContent = (v==null?"—":v) + " ";
const x = document.createElement("span");
x.className = "pill"; x.textContent = "×"; x.title = "hide this model (persists)";
x.onclick = (e)=>{ e.stopPropagation(); hideModel(m.model); };
td.appendChild(x);
} else {
td.innerHTML = col.fmt ? col.fmt(v) : (v==null?"—":v);
}
td.innerHTML = col.fmt ? col.fmt(v) : (v==null?"—":v);
if ((col.k==="ptsPerMin" || col.k==="ptsPerRun" || col.k==="points") && v!=null) td.classList.add(v<0 ? "bad" : "good");
if (col.k==="fpPen" && v<0) td.classList.add("bad");
if (col.k==="solo" && v>0) td.classList.add("good");
@@ -363,23 +427,11 @@ function render(){
}
mb.appendChild(tr);
}
// hidden-models panel: click a model to restore it
const hid = document.getElementById("hidden");
if (HIDDEN.size){
hid.innerHTML = "";
const lab = document.createElement("span"); lab.textContent = "hidden ("+HIDDEN.size+"): "; hid.appendChild(lab);
for (const m of [...HIDDEN].sort()){
const p = document.createElement("span"); p.className="pill"; p.textContent = " "+m;
p.title = "show this model again"; p.style.marginRight="6px";
p.onclick = ()=> showModel(m);
hid.appendChild(p);
}
const all = document.createElement("button"); all.className="link"; all.textContent="show all";
all.onclick = showAllModels; hid.appendChild(all);
hid.style.display = "";
} else {
hid.style.display = "none"; hid.innerHTML = "";
}
// picker buttons reflect current scope
document.getElementById("prbtn").textContent =
SELPRS.size ? `${SELPRS.size} PR${SELPRS.size===1?"":"s"}` : "all PRs ▾";
document.getElementById("modelsbtn").textContent =
HIDDEN.size ? `${HIDDEN.size} hidden ▾` : "all ▾";
const tot = models.reduce((a,m)=>({runs:a.runs+m.runs, min:a.min+m.minutes, find:a.find+m.findings, conf:a.conf+m.confirmed, pts:a.pts+m.points}), {runs:0,min:0,find:0,conf:0,pts:0});
const prNote = f.prs.size
@@ -412,13 +464,14 @@ function esc(s){ return (s==null?"":String(s)).replace(/[&<>]/g, m=>({"&":"&amp;
function resetFilters(){
for (const id of ["from","to","q"]) document.getElementById(id).value="";
for (const id of ["repo","provider","model","lens","grade"]) document.getElementById(id).value="";
for (const o of document.getElementById("pr").options) o.selected = false;
SELPRS.clear(); // hidden models are a persistent preference, not a filter — reset leaves them
selModel = null; render();
}
document.addEventListener("input", e=>{
if (e.target.closest("main")) render();
});
document.addEventListener("keydown", e=>{ if (e.key === "Escape") closeModal(); });
load();
</script>
</body>