feat(ui): PR picker becomes a persistent excluder, newest-first
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:
@@ -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
|
||||
findings detail table.
|
||||
|
||||
Comparisons can be scoped to **specific 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
|
||||
want and the entire table (runs, minutes, findings, points) counts only those, so a model with 2
|
||||
runs can be compared against one with 60 on exactly the work you choose. **all**/**none** apply to
|
||||
the current search, so you can filter to a repo and select all its PRs in one click.
|
||||
Comparisons can be scoped by **excluding PRs**: the **PRs** button opens a searchable checkbox popup
|
||||
listing every `repo#pr` newest-first, each with model coverage and last-review date
|
||||
(`steve/x#12 · 3/5 models · 2026-07-01`) — untick a PR and the entire table (runs, minutes,
|
||||
findings, points) stops counting it. It's an *exclusion* (not an opt-in) so it persists in
|
||||
`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
|
||||
points curve (default `trivial=1, small=3, medium=5, high=8, critical=20`) and computes
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
#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 { 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:hover { background:#1d212b; }
|
||||
#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>to</label><input type="date" id="to"></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>provider</label><select id="provider"></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();
|
||||
function saveHidden(){ localStorage.setItem("grt-hidden", JSON.stringify([...HIDDEN].sort())); }
|
||||
|
||||
// PRs the comparison is scoped to (repo#pr keys). Empty = every PR counts.
|
||||
let SELPRS = new Set();
|
||||
// PRs excluded from the comparison (repo#pr keys), persisted like HIDDEN.
|
||||
// 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;
|
||||
function openModal(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("modalback").style.display = "block";
|
||||
document.getElementById("modal").style.display = "flex";
|
||||
@@ -172,20 +181,23 @@ function closeModal(){
|
||||
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.
|
||||
// always has every PR / model. checked = counted; untick to exclude.
|
||||
function modalItems(){
|
||||
const allModels = uniq([...RUNS.map(r=>r.model), ...ROWS.map(r=>r.model)]);
|
||||
if (modalKind === "pr"){
|
||||
const byPR = new Map();
|
||||
const byPR = new Map(), last = 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 t = r.created_at || r.reported_at || "";
|
||||
if (t > (last.get(k) || "")) last.set(k, t);
|
||||
}
|
||||
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) }));
|
||||
// reverse chronological: most recently reviewed PR first
|
||||
return [...byPR.keys()].sort((a,b)=> (last.get(b)||"").localeCompare(last.get(a)||""))
|
||||
.map(k => ({ value:k, label: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) }));
|
||||
}
|
||||
@@ -194,8 +206,9 @@ function visibleModalItems(){
|
||||
return modalItems().filter(it => !q || it.label.toLowerCase().includes(q));
|
||||
}
|
||||
function fillModal(){
|
||||
const items = modalItems(), vis = visibleModalItems();
|
||||
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 cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = it.checked;
|
||||
cb.onchange = ()=> modalToggle(it.value, cb.checked);
|
||||
@@ -204,12 +217,14 @@ function fillModal(){
|
||||
lab.append(cb, name, note);
|
||||
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"
|
||||
? (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");
|
||||
? `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")
|
||||
: `showing ${counts} models · ` + (off ? `${off} 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); }
|
||||
if (modalKind === "pr"){ if (on) EXPRS.delete(v); else EXPRS.add(v); saveXPRs(); }
|
||||
else {
|
||||
if (on) HIDDEN.delete(v); else { HIDDEN.add(v); if (selModel===v) selModel=null; }
|
||||
saveHidden();
|
||||
@@ -217,14 +232,14 @@ function modalToggle(v, on){
|
||||
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.
|
||||
// name and exclude/restore 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); }
|
||||
if (modalKind === "pr"){ if (on) EXPRS.delete(it.value); else EXPRS.add(it.value); }
|
||||
else if (on) HIDDEN.delete(it.value);
|
||||
else { HIDDEN.add(it.value); if (selModel===it.value) selModel=null; }
|
||||
}
|
||||
if (modalKind !== "pr") saveHidden();
|
||||
if (modalKind === "pr") saveXPRs(); else saveHidden();
|
||||
fillModal(); render();
|
||||
}
|
||||
|
||||
@@ -269,7 +284,6 @@ function opt(sel, vals, label){
|
||||
if (vals.includes(cur)) sel.value = cur;
|
||||
}
|
||||
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(){
|
||||
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");
|
||||
@@ -299,7 +313,6 @@ function filters(){
|
||||
return {
|
||||
from: document.getElementById("from").value,
|
||||
to: document.getElementById("to").value,
|
||||
prs: SELPRS,
|
||||
repo: document.getElementById("repo").value,
|
||||
provider: document.getElementById("provider").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); }
|
||||
// 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)); }
|
||||
// prOK drops runs/rows from excluded PRs; everything else (including PRs that
|
||||
// arrive after the exclusions were set) counts.
|
||||
function prOK(o){ return !EXPRS.has(prKey(o)); }
|
||||
// run-level filters only (date/repo/provider/model/pr) — severity/lens/search are finding-level.
|
||||
function runMatch(r, f){
|
||||
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){
|
||||
if (!g) return true;
|
||||
@@ -332,7 +345,7 @@ function rowMatch(row, f){
|
||||
if (f.lens && row.lens!==f.lens) 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 (!prOK(row, f)) return false;
|
||||
if (!prOK(row)) return false;
|
||||
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,
|
||||
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; prsSeen.add(prKey(r)); const m=get(r.model); m.runs++; m.minutes += (r.duration_secs||0)/60;
|
||||
for (const r of RUNS){ if(!runMatch(r,f)) continue; 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; }
|
||||
|
||||
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);
|
||||
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)); }
|
||||
@@ -378,7 +390,7 @@ function aggregate(f){
|
||||
ptsPerRun: m.runs>0 ? points/m.runs : null,
|
||||
confirmedPct: findings>0 ? confirmed/findings*100 : null };
|
||||
}).filter(m => (m.runs>0 || m.findings>0) && !HIDDEN.has(m.model));
|
||||
return { models: out, rows, prsSeen };
|
||||
return { models: out, rows };
|
||||
}
|
||||
|
||||
const COLS = [
|
||||
@@ -395,7 +407,7 @@ const COLS = [
|
||||
|
||||
function render(){
|
||||
const f = filters();
|
||||
const { models, rows, prsSeen } = aggregate(f);
|
||||
const { models, rows } = aggregate(f);
|
||||
models.sort((a,b)=>{
|
||||
let x=a[sortKey], y=b[sortKey];
|
||||
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);
|
||||
}
|
||||
// picker buttons reflect current scope
|
||||
const exN = excludedCount();
|
||||
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 =
|
||||
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
|
||||
? ` · <b>scoped to ${prsSeen.size} PR${prsSeen.size===1?"":"s"}</b>` : "";
|
||||
const prNote = exN ? ` · <b>${exN} PR${exN===1?"":"s"} excluded</b>` : "";
|
||||
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` +
|
||||
prNote +
|
||||
@@ -464,7 +476,7 @@ function esc(s){ return (s==null?"":String(s)).replace(/[&<>]/g, m=>({"&":"&
|
||||
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="";
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user