feat(ui): PR picker becomes a persistent excluder, newest-first
Build & push image / build-and-push (push) Successful in 13s
CI / test (push) Successful in 10m43s

Invert the PR scope from opt-in to exclusion: untick a PR to drop it
from the comparison; the excluded set persists in localStorage and new
PRs are included automatically as they arrive. The list is now reverse
chronological (last run/report first) with the date shown per PR, the
footer states the total count so truncation fears are checkable at a
glance, and the scrollable list is pinned with min-height:0 for
robustness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 05:55:32 -04:00
parent 7fce78a664
commit e381c0ad41
2 changed files with 54 additions and 40 deletions
+7 -5
View File
@@ -138,11 +138,13 @@ ungraded, points, **points-per-minute**, points-per-run, by-severity — with **
(date range, repo, provider, model, lens, grade/severity), free-text search, and a click-to-scope (date range, repo, provider, model, lens, grade/severity), free-text search, and a click-to-scope
findings detail table. findings detail table.
Comparisons can be scoped to **specific PRs**: the **PRs** button opens a searchable checkbox popup Comparisons can be scoped by **excluding PRs**: the **PRs** button opens a searchable checkbox popup
listing every `repo#pr` with how many models ran it (`steve/x#12 · 3/5 models`) — tick the PRs you listing every `repo#pr` newest-first, each with model coverage and last-review date
want and the entire table (runs, minutes, findings, points) counts only those, so a model with 2 (`steve/x#12 · 3/5 models · 2026-07-01`) — untick a PR and the entire table (runs, minutes,
runs can be compared against one with 60 on exactly the work you choose. **all**/**none** apply to findings, points) stops counting it. It's an *exclusion* (not an opt-in) so it persists in
the current search, so you can filter to a repo and select all its PRs in one click. `localStorage` and **new PRs are included automatically** as they arrive; reset doesn't touch it.
**all**/**none** apply to the current search, so you can filter to a repo and exclude or restore all
its PRs in one click.
True to the store's "no points" rule, **scoring lives in the browser**: the page has an editable True to the store's "no points" rule, **scoring lives in the browser**: the page has an editable
points curve (default `trivial=1, small=3, medium=5, high=8, critical=20`) and computes points curve (default `trivial=1, small=3, medium=5, high=8, critical=20`) and computes
+47 -35
View File
@@ -29,7 +29,7 @@
#modal .mhead { display:flex; gap:8px; align-items:center; padding:10px 12px; border-bottom:1px solid var(--line); } #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 b { margin-right:auto; white-space:nowrap; }
#modal .mhead input { flex:1; min-width:80px; width:auto; } #modal .mhead input { flex:1; min-width:80px; width:auto; }
#mlist { overflow:auto; padding:6px 0; } #mlist { flex:1 1 auto; min-height:0; 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 { display:flex; gap:10px; align-items:center; padding:6px 14px; cursor:pointer; font-size:13px; }
#mlist label.item:hover { background:#1d212b; } #mlist label.item:hover { background:#1d212b; }
#mlist .note { margin-left:auto; color:var(--mut); font-size:12px; } #mlist .note { margin-left:auto; color:var(--mut); font-size:12px; }
@@ -76,7 +76,7 @@
<div class="f"><label>from</label><input type="date" id="from"></div> <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>to</label><input type="date" id="to"></div>
<div class="f"><label>repo</label><select id="repo"></select></div> <div class="f"><label>repo</label><select id="repo"></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>PRs</label><button id="prbtn" onclick="openModal('pr')" title="Exclude PRs from the comparison (persists in this browser). New PRs are included automatically as they arrive.">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>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>provider</label><select id="provider"></select></div>
<div class="f"><label>model</label><select id="model"></select></div> <div class="f"><label>model</label><select id="model"></select></div>
@@ -152,14 +152,23 @@ function loadHidden(){ try { return new Set(JSON.parse(localStorage.getItem("grt
let HIDDEN = loadHidden(); let HIDDEN = loadHidden();
function saveHidden(){ localStorage.setItem("grt-hidden", JSON.stringify([...HIDDEN].sort())); } function saveHidden(){ localStorage.setItem("grt-hidden", JSON.stringify([...HIDDEN].sort())); }
// PRs the comparison is scoped to (repo#pr keys). Empty = every PR counts. // PRs excluded from the comparison (repo#pr keys), persisted like HIDDEN.
let SELPRS = new Set(); // Exclusion (not opt-in) so new PRs count automatically as they arrive.
function loadXPRs(){ try { return new Set(JSON.parse(localStorage.getItem("grt-xprs") || "[]")); } catch { return new Set(); } }
let EXPRS = loadXPRs();
function saveXPRs(){ localStorage.setItem("grt-xprs", JSON.stringify([...EXPRS].sort())); }
// excluded PRs actually present in the current data (EXPRS may hold stale keys)
function excludedCount(){
const seen = new Set();
for (const r of [...RUNS, ...ROWS]){ const k = prKey(r); if (EXPRS.has(k)) seen.add(k); }
return seen.size;
}
// ---- picker modal (shared by the PR scope and the model hider) ---- // ---- picker modal (shared by the PR excluder and the model hider) ----
let modalKind = null; let modalKind = null;
function openModal(kind){ function openModal(kind){
modalKind = kind; modalKind = kind;
document.getElementById("mtitle").textContent = kind==="pr" ? "compare only these PRs" : "models shown"; document.getElementById("mtitle").textContent = kind==="pr" ? "PRs compared" : "models shown";
document.getElementById("msearch").value = ""; document.getElementById("msearch").value = "";
document.getElementById("modalback").style.display = "block"; document.getElementById("modalback").style.display = "block";
document.getElementById("modal").style.display = "flex"; document.getElementById("modal").style.display = "flex";
@@ -172,20 +181,23 @@ function closeModal(){
document.getElementById("modal").style.display = "none"; document.getElementById("modal").style.display = "none";
} }
// The full item list, rebuilt from the raw data on every open/refresh so it // The full item list, rebuilt from the raw data on every open/refresh so it
// always has every PR / model. checked = in the comparison. // always has every PR / model. checked = counted; untick to exclude.
function modalItems(){ function modalItems(){
const allModels = uniq([...RUNS.map(r=>r.model), ...ROWS.map(r=>r.model)]); const allModels = uniq([...RUNS.map(r=>r.model), ...ROWS.map(r=>r.model)]);
if (modalKind === "pr"){ if (modalKind === "pr"){
const byPR = new Map(); const byPR = new Map(), last = new Map();
for (const r of [...RUNS, ...ROWS]){ for (const r of [...RUNS, ...ROWS]){
const k = prKey(r); const k = prKey(r);
if (!byPR.has(k)) byPR.set(k, new Set()); if (!byPR.has(k)) byPR.set(k, new Set());
if (r.model) byPR.get(k).add(r.model); if (r.model) byPR.get(k).add(r.model);
const t = r.created_at || r.reported_at || "";
if (t > (last.get(k) || "")) last.set(k, t);
} }
return [...byPR.keys()].sort((a,b)=>{ // reverse chronological: most recently reviewed PR first
const [ra,pa] = splitPR(a), [rb,pb] = splitPR(b); return [...byPR.keys()].sort((a,b)=> (last.get(b)||"").localeCompare(last.get(a)||""))
return ra===rb ? pb-pa : ra.localeCompare(rb); // newest PR first within a repo .map(k => ({ value:k, label:k,
}).map(k => ({ value:k, label:k, note:`${byPR.get(k).size}/${allModels.length} models`, checked:SELPRS.has(k) })); note:`${byPR.get(k).size}/${allModels.length} models · ${(last.get(k)||"").slice(0,10)}`,
checked:!EXPRS.has(k) }));
} }
return allModels.map(m => ({ value:m, label:m, note:HIDDEN.has(m)?"hidden":"", checked:!HIDDEN.has(m) })); return allModels.map(m => ({ value:m, label:m, note:HIDDEN.has(m)?"hidden":"", checked:!HIDDEN.has(m) }));
} }
@@ -194,8 +206,9 @@ function visibleModalItems(){
return modalItems().filter(it => !q || it.label.toLowerCase().includes(q)); return modalItems().filter(it => !q || it.label.toLowerCase().includes(q));
} }
function fillModal(){ function fillModal(){
const items = modalItems(), vis = visibleModalItems();
const list = document.getElementById("mlist"); list.innerHTML = ""; const list = document.getElementById("mlist"); list.innerHTML = "";
for (const it of visibleModalItems()){ for (const it of vis){
const lab = document.createElement("label"); lab.className = "item"; const lab = document.createElement("label"); lab.className = "item";
const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = it.checked; const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = it.checked;
cb.onchange = ()=> modalToggle(it.value, cb.checked); cb.onchange = ()=> modalToggle(it.value, cb.checked);
@@ -204,12 +217,14 @@ function fillModal(){
lab.append(cb, name, note); lab.append(cb, name, note);
list.appendChild(lab); list.appendChild(lab);
} }
const off = items.filter(it=>!it.checked).length;
const counts = vis.length===items.length ? `all ${items.length}` : `${vis.length} of ${items.length}`;
document.getElementById("mfoot").textContent = modalKind==="pr" 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") ? `showing ${counts} PRs · ` + (off ? `${off} excluded — stays excluded as new PRs arrive (persists in this browser)` : "none excluded — every PR counts, new ones included automatically")
: (HIDDEN.size ? `${HIDDEN.size} hidden — excluded from the scoreboard (persists in this browser)` : "all models shown"); : `showing ${counts} models · ` + (off ? `${off} hidden — excluded from the scoreboard (persists in this browser)` : "all models shown");
} }
function modalToggle(v, on){ function modalToggle(v, on){
if (modalKind === "pr"){ if (on) SELPRS.add(v); else SELPRS.delete(v); } if (modalKind === "pr"){ if (on) EXPRS.delete(v); else EXPRS.add(v); saveXPRs(); }
else { else {
if (on) HIDDEN.delete(v); else { HIDDEN.add(v); if (selModel===v) selModel=null; } if (on) HIDDEN.delete(v); else { HIDDEN.add(v); if (selModel===v) selModel=null; }
saveHidden(); saveHidden();
@@ -217,14 +232,14 @@ function modalToggle(v, on){
fillModal(); render(); fillModal(); render();
} }
// all/none apply to the search-filtered items, so you can e.g. type a repo // all/none apply to the search-filtered items, so you can e.g. type a repo
// name and select all its PRs at once. // name and exclude/restore all its PRs at once.
function modalSetAll(on){ function modalSetAll(on){
for (const it of visibleModalItems()){ for (const it of visibleModalItems()){
if (modalKind === "pr"){ if (on) SELPRS.add(it.value); else SELPRS.delete(it.value); } if (modalKind === "pr"){ if (on) EXPRS.delete(it.value); else EXPRS.add(it.value); }
else if (on) HIDDEN.delete(it.value); else if (on) HIDDEN.delete(it.value);
else { HIDDEN.add(it.value); if (selModel===it.value) selModel=null; } else { HIDDEN.add(it.value); if (selModel===it.value) selModel=null; }
} }
if (modalKind !== "pr") saveHidden(); if (modalKind === "pr") saveXPRs(); else saveHidden();
fillModal(); render(); fillModal(); render();
} }
@@ -269,7 +284,6 @@ function opt(sel, vals, label){
if (vals.includes(cur)) sel.value = cur; if (vals.includes(cur)) sel.value = cur;
} }
function prKey(o){ return o.repo + "#" + o.pr; } function prKey(o){ return o.repo + "#" + o.pr; }
function splitPR(k){ const i = k.lastIndexOf("#"); return [k.slice(0,i), +k.slice(i+1)]; }
function buildFacets(){ function buildFacets(){
opt(document.getElementById("repo"), uniq([...RUNS.map(r=>r.repo), ...ROWS.map(r=>r.repo)]), "all repos"); 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("provider"), uniq([...RUNS.map(r=>r.provider), ...ROWS.map(r=>r.provider)]), "all providers");
@@ -299,7 +313,6 @@ function filters(){
return { return {
from: document.getElementById("from").value, from: document.getElementById("from").value,
to: document.getElementById("to").value, to: document.getElementById("to").value,
prs: SELPRS,
repo: document.getElementById("repo").value, repo: document.getElementById("repo").value,
provider: document.getElementById("provider").value, provider: document.getElementById("provider").value,
model: document.getElementById("model").value, model: document.getElementById("model").value,
@@ -309,13 +322,13 @@ function filters(){
}; };
} }
function dateOK(ts, f){ const d = (ts||"").slice(0,10); return (!f.from || d >= f.from) && (!f.to || d <= f.to); } 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 picker: no selection = every PR counts, // prOK drops runs/rows from excluded PRs; everything else (including PRs that
// regardless of which models ran it. // arrive after the exclusions were set) counts.
function prOK(o, f){ return !f.prs.size || f.prs.has(prKey(o)); } function prOK(o){ return !EXPRS.has(prKey(o)); }
// run-level filters only (date/repo/provider/model/pr) — severity/lens/search are finding-level. // run-level filters only (date/repo/provider/model/pr) — severity/lens/search are finding-level.
function runMatch(r, f){ function runMatch(r, f){
return dateOK(r.created_at, f) && (!f.repo || r.repo===f.repo) && return dateOK(r.created_at, f) && (!f.repo || r.repo===f.repo) &&
(!f.provider || r.provider===f.provider) && (!f.model || r.model===f.model) && prOK(r, f); (!f.provider || r.provider===f.provider) && (!f.model || r.model===f.model) && prOK(r);
} }
function gradeMatch(row, g){ function gradeMatch(row, g){
if (!g) return true; if (!g) return true;
@@ -332,7 +345,7 @@ function rowMatch(row, f){
if (f.lens && row.lens!==f.lens) return false; if (f.lens && row.lens!==f.lens) return false;
if (!gradeMatch(row, f.grade)) return false; if (!gradeMatch(row, f.grade)) return false;
if (f.q && !((row.title||"")+" "+(row.file||"")+" "+(row.repo||"")).toLowerCase().includes(f.q)) return false; if (f.q && !((row.title||"")+" "+(row.file||"")+" "+(row.repo||"")).toLowerCase().includes(f.q)) return false;
if (!prOK(row, f)) return false; if (!prOK(row)) return false;
return true; return true;
} }
@@ -346,12 +359,11 @@ function aggregate(f){
const get = m => { if(!M.has(m)) M.set(m, {model:m, provider:"", runs:0, minutes:0, inTok:0, outTok:0, const get = m => { if(!M.has(m)) M.set(m, {model:m, provider:"", runs:0, minutes:0, inTok:0, outTok:0,
findings:new Set(), confirmed:new Map(), fp:new Map(), ungraded:new Set()}); return M.get(m); }; findings:new Set(), confirmed:new Map(), fp:new Map(), ungraded:new Set()}); return M.get(m); };
const prsSeen = new Set(); for (const r of RUNS){ if(!runMatch(r,f)) continue; const m=get(r.model); m.runs++; m.minutes += (r.duration_secs||0)/60;
for (const r of RUNS){ if(!runMatch(r,f)) continue; prsSeen.add(prKey(r)); const m=get(r.model); m.runs++; m.minutes += (r.duration_secs||0)/60;
m.inTok += r.input_tokens||0; m.outTok += r.output_tokens||0; if(r.provider) m.provider=r.provider; } m.inTok += r.input_tokens||0; m.outTok += r.output_tokens||0; if(r.provider) m.provider=r.provider; }
const rows = ROWS.filter(r => rowMatch(r, f) && !HIDDEN.has(r.model)); const rows = ROWS.filter(r => rowMatch(r, f) && !HIDDEN.has(r.model));
for (const r of rows){ prsSeen.add(prKey(r)); const m=get(r.model); if(r.provider) m.provider=m.provider||r.provider; for (const r of rows){ const m=get(r.model); if(r.provider) m.provider=m.provider||r.provider;
m.findings.add(r.finding_id); m.findings.add(r.finding_id);
if (r.graded && r.is_real === true){ m.confirmed.set(r.finding_id, r.severity || ""); } if (r.graded && r.is_real === true){ m.confirmed.set(r.finding_id, r.severity || ""); }
else if (r.graded && r.is_real === false){ m.fp.set(r.finding_id, rawToSevKey(r.raw_severity)); } else if (r.graded && r.is_real === false){ m.fp.set(r.finding_id, rawToSevKey(r.raw_severity)); }
@@ -378,7 +390,7 @@ function aggregate(f){
ptsPerRun: m.runs>0 ? points/m.runs : null, ptsPerRun: m.runs>0 ? points/m.runs : null,
confirmedPct: findings>0 ? confirmed/findings*100 : null }; confirmedPct: findings>0 ? confirmed/findings*100 : null };
}).filter(m => (m.runs>0 || m.findings>0) && !HIDDEN.has(m.model)); }).filter(m => (m.runs>0 || m.findings>0) && !HIDDEN.has(m.model));
return { models: out, rows, prsSeen }; return { models: out, rows };
} }
const COLS = [ const COLS = [
@@ -395,7 +407,7 @@ const COLS = [
function render(){ function render(){
const f = filters(); const f = filters();
const { models, rows, prsSeen } = aggregate(f); const { models, rows } = aggregate(f);
models.sort((a,b)=>{ models.sort((a,b)=>{
let x=a[sortKey], y=b[sortKey]; let x=a[sortKey], y=b[sortKey];
if (sortKey==="model"||sortKey==="provider"){ x=x||""; y=y||""; return sortAsc ? x.localeCompare(y) : y.localeCompare(x); } if (sortKey==="model"||sortKey==="provider"){ x=x||""; y=y||""; return sortAsc ? x.localeCompare(y) : y.localeCompare(x); }
@@ -428,14 +440,14 @@ function render(){
mb.appendChild(tr); mb.appendChild(tr);
} }
// picker buttons reflect current scope // picker buttons reflect current scope
const exN = excludedCount();
document.getElementById("prbtn").textContent = document.getElementById("prbtn").textContent =
SELPRS.size ? `${SELPRS.size} PR${SELPRS.size===1?"":"s"}` : "all PRs ▾"; exN ? `${exN} PR${exN===1?"":"s"} excluded` : "all PRs ▾";
document.getElementById("modelsbtn").textContent = document.getElementById("modelsbtn").textContent =
HIDDEN.size ? `${HIDDEN.size} hidden ▾` : "all ▾"; 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 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 const prNote = exN ? ` · <b>${exN} PR${exN===1?"":"s"} excluded</b>` : "";
? ` · <b>scoped to ${prsSeen.size} PR${prsSeen.size===1?"":"s"}</b>` : "";
document.getElementById("summary").innerHTML = document.getElementById("summary").innerHTML =
`${models.length} models · ${tot.runs} runs · ${tot.min.toFixed(0)} min · ${tot.find} findings · ${tot.conf} confirmed · ${tot.pts.toFixed(0)} pts` + `${models.length} models · ${tot.runs} runs · ${tot.min.toFixed(0)} min · ${tot.find} findings · ${tot.conf} confirmed · ${tot.pts.toFixed(0)} pts` +
prNote + prNote +
@@ -464,7 +476,7 @@ function esc(s){ return (s==null?"":String(s)).replace(/[&<>]/g, m=>({"&":"&amp;
function resetFilters(){ function resetFilters(){
for (const id of ["from","to","q"]) document.getElementById(id).value=""; 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 id of ["repo","provider","model","lens","grade"]) document.getElementById(id).value="";
SELPRS.clear(); // hidden models are a persistent preference, not a filter — reset leaves them // excluded PRs and hidden models are persistent preferences, not filters — reset leaves them
selModel = null; render(); selModel = null; render();
} }