From e381c0ad4100713df4763887dd4cd57d03c16075 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Fri, 3 Jul 2026 05:55:32 -0400 Subject: [PATCH] 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 --- README.md | 12 ++++---- ui.html | 82 +++++++++++++++++++++++++++++++------------------------ 2 files changed, 54 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 4793cc3..aa49383 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ui.html b/ui.html index 779e9e1..9b905e3 100644 --- a/ui.html +++ b/ui.html @@ -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 @@
-
+
@@ -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 - ? ` · scoped to ${prsSeen.size} PR${prsSeen.size===1?"":"s"}` : ""; + const prNote = exN ? ` · ${exN} PR${exN===1?"":"s"} excluded` : ""; 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(); }