pi-mono/scripts/issues/ui.html

302 lines
16 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>pi issues</title>
<style>
:root{--bg:#fbfaf8;--card:#fff;--text:#1d1b19;--muted:#77716a;--line:#e7e4df;--hover:#f3f0eb;--p0:#c8433d;--p1:#c98a1b;--none:#8a847c;--ok:#2f8f5b;--accent:#5b4bd6;font:15px/1.45 -apple-system,BlinkMacSystemFont,Inter,"Segoe UI",Roboto,sans-serif;color-scheme:light dark}
@media (prefers-color-scheme:dark){:root{--bg:#151413;--card:#1c1b19;--text:#ecebe8;--muted:#9b958d;--line:#2c2a27;--hover:#24221f;--accent:#9d91ff}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text)}
a{color:var(--accent);text-decoration:none}
header{position:sticky;top:0;z-index:2;background:var(--bg);border-bottom:1px solid var(--line);padding:10px 14px}
.bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;max-width:860px;margin:0 auto}
h1{font-size:17px;margin:0 8px 0 0}
.status{font-size:12px;color:var(--muted);max-width:860px;margin:4px auto 0}
.tabs{display:flex;gap:2px}
.tab,.btn,select{font:inherit;font-size:14px;border:1px solid var(--line);background:var(--card);color:var(--text);border-radius:7px;padding:5px 10px;cursor:pointer}
.opt{font-size:14px;display:flex;gap:4px;align-items:center;cursor:pointer}
.tab.on{background:var(--text);color:var(--bg);border-color:var(--text)}
main{max-width:860px;margin:0 auto;padding:8px 10px 60px}
.grp{display:flex;justify-content:space-between;align-items:center;margin:18px 4px 6px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin:8px 0}
.card.sel{outline:2px solid var(--accent)}
.meta{font-size:12.5px;color:var(--muted);display:flex;gap:6px;flex-wrap:wrap}
.title{font-weight:600;margin:3px 0 6px;display:block;color:var(--text)}
.sum{margin:0 0 8px}
.chips{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:8px}
.chip{font-size:11.5px;padding:0 7px;border-radius:9px;border:1px solid var(--line);color:var(--muted)}
.chip.warn{border-color:#d9a441;color:#b07d14}.chip.bad{border-color:var(--p0);color:var(--p0)}
.dups{font-size:13px;margin:0 0 8px;padding-left:0;list-style:none}
.dups li{margin:2px 0}
.dups .k{font-size:11px;font-weight:600;text-transform:uppercase}
.dups .k.duplicate{color:var(--p0)}.dups .k.related{color:var(--muted)}
.ai{font-size:13px;color:var(--muted);margin-bottom:10px}
.ai b{color:var(--text)}
.acts{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.btn.p0.on{background:var(--p0);border-color:var(--p0);color:#fff}
.btn.p1.on{background:var(--p1);border-color:var(--p1);color:#fff}
.btn.none.on{background:var(--none);border-color:var(--none);color:#fff}
.btn.small{font-size:12px;padding:3px 8px}
details{margin-top:8px;font-size:13px}
summary{cursor:pointer;color:var(--muted)}
pre{white-space:pre-wrap;word-break:break-word;font:12.5px/1.45 ui-monospace,Menlo,monospace;background:var(--hover);padding:8px;border-radius:6px;max-height:340px;overflow:auto}
.dec{font-size:11px;font-weight:700;padding:1px 7px;border-radius:4px;color:#fff}
.dec.p0{background:var(--p0)}.dec.p1{background:var(--p1)}.dec.none{background:var(--none)}
.empty{color:var(--muted);text-align:center;margin:40px 0}
.err{color:var(--p0)}
dialog{border:1px solid var(--line);border-radius:10px;background:var(--card);color:var(--text);padding:16px 20px;min-width:280px}
dialog::backdrop{background:rgb(0 0 0 / .35)}
dialog h2{font-size:15px;margin:0 0 10px}
dialog table{border-collapse:collapse;font-size:14px}
dialog td{padding:3px 12px 3px 0}
kbd{font:12.5px ui-monospace,Menlo,monospace;border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:1px 6px;background:var(--hover)}
.conv{margin:6px 0 0;display:flex;flex-direction:column;gap:6px}
.conv .msg{white-space:pre-wrap;word-break:break-word}
.conv .who{font-size:11px;font-weight:600;text-transform:uppercase;color:var(--muted)}
.conv .thinking{color:var(--muted);font-style:italic}
.conv details{margin:0}
.conv summary code{font:12.5px ui-monospace,Menlo,monospace;color:var(--text)}
</style>
</head>
<body>
<header>
<div class="bar">
<h1>pi issues</h1>
<div class="tabs">
<button class="tab on" data-view="inbox">Inbox</button>
<button class="tab" data-view="decided">Decided</button>
<button class="tab" data-view="all">All</button>
</div>
<select id="cat"><option value="">All categories</option></select>
<select id="sort">
<option value="suggestion">By suggestion</option>
<option value="desc">Newest first</option>
<option value="asc">Oldest first</option>
</select>
<label class="opt"><input type="checkbox" id="related"> Show related</label>
<button class="btn" id="sync">Sync</button>
<button class="btn" id="help" title="Keyboard shortcuts">?</button>
</div>
<div class="status" id="status"></div>
</header>
<main id="main"></main>
<dialog id="keys">
<h2>Keyboard shortcuts</h2>
<table>
<tr><td><kbd>j</kbd> <kbd>k</kbd></td><td>Next / previous issue</td></tr>
<tr><td><kbd>0</kbd></td><td>Set p0</td></tr>
<tr><td><kbd>1</kbd></td><td>Set p1</td></tr>
<tr><td><kbd>n</kbd></td><td>Set none</td></tr>
<tr><td><kbd>o</kbd></td><td>Open on GitHub</td></tr>
<tr><td><kbd>c</kbd></td><td>Show / hide the conversation</td></tr>
<tr><td><kbd>?</kbd></td><td>Show this help</td></tr>
<tr><td><kbd>Esc</kbd></td><td>Close</td></tr>
</table>
</dialog>
<script>
let data = null, view = "inbox", cat = "", sel = -1, openDetails = new Set();
let sort = localStorage.getItem("sort") || "suggestion";
let showRelated = localStorage.getItem("showRelated") !== "0";
// Fetched transcripts by issue number; refreshed on every load while their section is open.
const convs = new Map();
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
const gh = (n) => `https://github.com/${data.repo}/issues/${n}`;
const ago = (iso) => { const h = (Date.now() - Date.parse(iso)) / 3.6e6; return h < 1 ? `${Math.round(h * 60)}m` : h < 48 ? `${Math.round(h)}h` : `${Math.round(h / 24)}d`; };
const category = (it) => it.decision?.category || it.triage?.result?.category || "";
const WARN = new Set(["theoretical", "AI-written", "third-party", "upstream", "maybe fixed", "no repro"]);
const BAD = new Set(["regression", "spam", "empty", "duplicate cluster"]);
async function load() {
const res = await fetch("/api/state");
if (!res.ok) { $("main").innerHTML = `<p class="empty">${res.status}: ${esc(await res.text())}</p>`; return; }
data = await res.json();
await Promise.all([...openDetails].filter((k) => k.startsWith("conv:")).map((k) => fetchConv(Number(k.slice(5)))));
render();
}
async function fetchConv(n) {
const res = await fetch(`/api/conversation?number=${n}`);
convs.set(n, res.ok ? (await res.json()).items : null);
}
function toolLine(t) {
const a = t.args ?? {};
if (t.name === "search_issues") return `search_issues "${a.query}"`;
if (t.name === "read_issue") return `read_issue #${a.number}`;
if (t.name === "submit_triage") return `submit_triage ${a.category}, ${a.suggestion}`;
return `${t.name} ${JSON.stringify(a).slice(0, 120)}`;
}
function transcript(n) {
if (!convs.has(n)) return `<p class="ai">Loading…</p>`;
const items = convs.get(n);
if (!items) return `<p class="ai">No conversation yet.</p>`;
return `<div class="conv">${items.map((it, i) => {
if (it.type === "tool") {
const key = `tool:${n}:${i}`;
const out = it.name === "submit_triage" ? JSON.stringify(it.args, null, 2) : it.output ?? "(running…)";
return `<details data-key="${key}" ${openDetails.has(key) ? "open" : ""}><summary><code>${esc(toolLine(it))}</code>${it.isError ? ` <span class="err">error</span>` : ""}</summary><pre>${esc(out)}</pre></details>`;
}
if (it.type === "user") {
const key = `prompt:${n}:${i}`;
return `<details data-key="${key}" ${openDetails.has(key) ? "open" : ""}><summary><span class="who">user</span> ${esc(it.text.split("\n")[0].slice(0, 100))}</summary><pre>${esc(it.text)}</pre></details>`;
}
return `<div class="msg ${it.type}"><span class="who">${it.type === "thinking" ? "thinking" : "agent"}</span> ${esc(it.text)}</div>`;
}).join("")}</div>`;
}
async function post(path, body) {
await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
await load();
}
function visible() {
return data.items.filter((it) => {
if (cat && category(it) !== cat) return false;
const decided = !!it.decision?.priority;
return view === "all" || (view === "decided" ? decided : !decided);
});
}
function groups(items) {
if (sort === "asc") return [["", [...items].sort((a, b) => a.number - b.number)]];
if (view !== "inbox" || sort === "desc") return [["", items]];
const by = { p0: [], p1: [], none: [], pending: [] };
for (const it of items) by[it.triage?.status === "done" ? it.triage.result.suggestion : "pending"].push(it);
return [["Suggested p0", by.p0], ["Suggested p1", by.p1], ["Suggested none", by.none], ["Not triaged yet", by.pending]];
}
function card(it, idx) {
const r = it.triage?.result, d = it.decision, st = it.authorStats;
const meta = [
`<a href="${gh(it.number)}" target="_blank">#${it.number}</a>`,
r ? `<span>${esc(category(it))}${d?.category ? " ✎" : ""}${r.confidence === "low" ? ` <span class="chip warn">low conf${r.alternative ? `, alt ${esc(r.alternative)}` : ""}</span>` : ""}</span>` : "",
`<span>@${esc(it.author)} (${st.prior ? `${st.actedOn}/${st.prior} acted on` : "first issue"}${it.association !== "NONE" && it.association !== "CONTRIBUTOR" ? `, ${esc(it.association.toLowerCase())}` : ""})</span>`,
`<span>${esc(it.status)}</span>`,
`<span>${ago(it.createdAt)}</span>`,
d?.priority ? `<span class="dec ${d.priority}">${d.priority}</span>` : "",
].filter(Boolean).join(" · ");
let body = "";
if (r) {
body += `<p class="sum">${esc(r.summary)}</p>`;
if (r.signals.length) body += `<div class="chips">${r.signals.map((s) => `<span class="chip ${BAD.has(s) ? "bad" : WARN.has(s) ? "warn" : ""}">${esc(s)}</span>`).join("")}</div>`;
const dups = showRelated ? r.duplicates : r.duplicates.filter((x) => x.kind === "duplicate");
if (dups.length) body += `<ul class="dups">${dups.map((x) => `<li><span class="k ${x.kind}">${x.kind}</span> <a href="${gh(x.number)}" target="_blank">#${x.number}</a> ${esc(x.title)} <span class="meta" style="display:inline">(${esc(x.status)})</span><br><span class="meta" style="display:inline">${esc(x.reason)}</span></li>`).join("")}</ul>`;
body += `<div class="ai">AI: <b>${r.suggestion}</b> — ${esc(r.reason)}</div>`;
} else if (it.triage?.status === "failed") {
body += `<p class="err">Triage failed: ${esc(it.triage.error)}</p>`;
} else {
body += `<p class="ai">${it.triage ? esc(it.triage.status) : "waiting"}…</p>`;
}
const pri = (p) => `<button class="btn ${p} ${d?.priority === p ? "on" : ""}" data-act="prio" data-p="${p}" data-n="${it.number}">${p}</button>`;
const cats = data.categories.map((c) => `<option ${c.name === category(it) ? "selected" : ""}>${c.name}</option>`).join("");
return `<div class="card ${idx === sel ? "sel" : ""}" data-idx="${idx}">
<div class="meta">${meta}</div>
<a class="title" href="${gh(it.number)}" target="_blank">${esc(it.title)}</a>
${body}
<div class="acts">${pri("p0")}${pri("p1")}${pri("none")}
<select data-act="cat" data-n="${it.number}">${r ? "" : "<option></option>"}${cats}</select>
</div>
${it.triage ? `<details data-key="conv:${it.number}" ${openDetails.has(`conv:${it.number}`) ? "open" : ""}><summary>Conversation</summary>${transcript(it.number)}</details>` : ""}
<details data-key="text:${it.number}" ${openDetails.has(`text:${it.number}`) ? "open" : ""}><summary>Issue text</summary>
<pre>${esc(it.body) || "(empty)"}</pre>
<button class="btn small" data-act="retriage" data-n="${it.number}">Triage again</button>
</details>
</div>`;
}
function render() {
const items = visible();
const sel0 = $("cat").value;
$("cat").innerHTML = `<option value="">All categories</option>` + data.categories.map((c) => `<option value="${c.name}">${c.name}${c.owner ? ` (${c.owner})` : ""}</option>`).join("");
$("cat").value = sel0;
const undecided = data.items.filter((it) => !it.decision?.priority).length;
const q = data.queue;
$("status").innerHTML = [
`${undecided} undecided of ${data.items.length} (untriaged, or new since ${data.inboxSince.slice(0, 10)})`,
q.queued || q.running ? `triaging ${q.running}, ${q.queued} queued` : "",
data.sync.running ? "syncing…" : data.sync.lastSync ? `synced ${ago(data.sync.lastSync)} ago` : "",
data.sync.lastError ? `<span class="err">sync: ${esc(data.sync.lastError)}</span>` : "",
`${esc(data.model)} $${data.cost.toFixed(2)}`,
].filter(Boolean).join(" · ");
let html = "", idx = 0;
for (const [name, list] of groups(items)) {
if (!list.length) continue;
if (name) html += `<div class="grp"><span>${name} (${list.length})</span>${name === "Suggested none" ? `<button class="btn small" data-act="allnone">Mark all none</button>` : ""}</div>`;
for (const it of list) html += card(it, idx++);
}
$("main").innerHTML = html || `<p class="empty">Nothing here.</p>`;
}
function flat() { return groups(visible()).flatMap(([, list]) => list); }
document.addEventListener("click", (e) => {
const t = e.target.closest("[data-act]");
if (t?.dataset.act === "prio") {
const it = data.items.find((x) => x.number == t.dataset.n);
post("/api/decide", { number: Number(t.dataset.n), priority: it.decision?.priority === t.dataset.p ? null : t.dataset.p });
} else if (t?.dataset.act === "retriage") post("/api/retriage", { number: Number(t.dataset.n) });
else if (t?.dataset.act === "allnone") {
const numbers = visible().filter((it) => it.triage?.result?.suggestion === "none").map((it) => it.number);
if (confirm(`Mark ${numbers.length} issues as none?`)) post("/api/decide", { numbers, priority: "none" });
}
const c = e.target.closest(".card");
if (c) { sel = Number(c.dataset.idx); document.querySelectorAll(".card").forEach((x) => x.classList.toggle("sel", x === c)); }
});
document.addEventListener("change", (e) => {
if (e.target.dataset.act === "cat") post("/api/decide", { number: Number(e.target.dataset.n), category: e.target.value });
if (e.target.id === "cat") { cat = e.target.value; sel = -1; render(); }
if (e.target.id === "related") { showRelated = e.target.checked; localStorage.setItem("showRelated", showRelated ? "1" : "0"); render(); }
if (e.target.id === "sort") { sort = e.target.value; localStorage.setItem("sort", sort); sel = -1; render(); }
});
document.addEventListener("toggle", (e) => {
const key = e.target.dataset?.key;
if (e.target.tagName !== "DETAILS" || !key || e.target.open === openDetails.has(key)) return;
if (!e.target.open) { openDetails.delete(key); return; }
openDetails.add(key);
if (key.startsWith("conv:")) {
const n = Number(key.slice(5));
fetchConv(n).then(render);
}
}, true);
document.querySelectorAll(".tab").forEach((t) => t.onclick = () => {
view = t.dataset.view; sel = -1;
document.querySelectorAll(".tab").forEach((x) => x.classList.toggle("on", x === t));
render();
});
$("sync").onclick = () => post("/api/sync", {});
$("help").onclick = () => $("keys").showModal();
// A click on the backdrop lands on the dialog element itself.
$("keys").onclick = (e) => { if (e.target === $("keys")) $("keys").close(); };
// Keyboard: j/k select, 0/1/n decide, o open on GitHub, c show the conversation, ? show the shortcuts.
document.addEventListener("keydown", (e) => {
if (e.key === "?" && !$("keys").open) { $("keys").showModal(); return; }
if (!data || $("keys").open || e.target.tagName === "SELECT" || e.metaKey || e.ctrlKey) return;
const list = flat(), it = list[sel];
if (e.key === "j") sel = Math.min(sel + 1, list.length - 1);
else if (e.key === "k") sel = Math.max(sel - 1, 0);
else if (it && e.key === "o") { window.open(gh(it.number), "_blank"); return; }
else if (it && e.key === "c") {
document.querySelector(`details[data-key="conv:${it.number}"]`)?.toggleAttribute("open");
return;
}
else if (it && ["0", "1", "n"].includes(e.key)) {
const p = { 0: "p0", 1: "p1", n: "none" }[e.key];
post("/api/decide", { number: it.number, priority: p });
return;
} else return;
render();
document.querySelector(".card.sel")?.scrollIntoView({ block: "nearest" });
});
$("sort").value = sort;
$("related").checked = showRelated;
load();
setInterval(() => { if (!document.hidden && !document.querySelector("select:focus")) load(); }, 8000);
</script>
</body>
</html>