feat: issue triage UI shows untriaged issues and agent conversations

This commit is contained in:
David Brailovsky 2026-11-02 23:25:24 +00:00 • committed by Mario Zechner
parent db8307ec95
commit 00967748dc
5 changed files with 158 additions and 15 deletions

View file

@ -8,7 +8,7 @@ A local tool for the daily issue review. It runs in this order:
- picks a category from `categories/*.md`
- writes a one-sentence summary and signals
- suggests p0, p1, or none
3. **Review.** A web UI lists the inbox: every issue created since the inbox start, grouped by the agent's suggestion. You set p0, p1, or none, and fix the category if needed. Each issue links to GitHub.
3. **Review.** A web UI lists the inbox: every issue created since the inbox start, plus every issue still labeled `untriaged` on GitHub (auto-closed by the issue gate and not reviewed yet), grouped by the agent's suggestion. You set p0, p1, or none, and fix the category if needed. Each issue links to GitHub.
Decisions are stored locally only. Nothing is written to GitHub yet.
@ -52,8 +52,12 @@ The agent authenticates through `~/.pi/agent/auth.json`, the same credentials pi
| `TRIAGE_SOCKET` | | Listen on this unix socket instead of the port, for a reverse proxy |
| `TRIAGE_DATA` | `scripts/issues/data` | State directory |
The "Show related" checkbox in the header hides or shows related issues; duplicates are always shown. The choice is saved in the browser.
## UI keys
- `j`/`k`: move between issues
- `0`, `1`, `n`: set p0, p1, none
- `o`: open the issue on GitHub
- `c`: show or hide the agent's conversation for the issue
- `?`: show the keyboard shortcuts

View file

@ -129,6 +129,11 @@ export function isActedOn(issue: Issue): boolean {
);
}
/** In the inbox: created since the inbox start, or still labeled `untriaged` (auto-closed by the issue gate and not reviewed yet). */
export function inInbox(issue: Issue, inboxSince: string): boolean {
return issue.createdAt >= inboxSince || issue.labels.includes("untriaged");
}
export function issueStatus(issue: Issue): string {
const labels = issue.labels.filter((label) => ["p0", "p1", "no-action", "untriaged"].includes(label));
const state = issue.state === "open" ? "open" : `closed${issue.stateReason ? `:${issue.stateReason}` : ""}`;

View file

@ -6,7 +6,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
import { Corpus, issueStatus, REPO } from "./github.ts";
import { Corpus, inInbox, issueStatus, REPO } from "./github.ts";
import { PRIORITIES, type Priority, Triage } from "./triage.ts";
const scriptDir = dirname(fileURLToPath(import.meta.url));
@ -54,7 +54,7 @@ async function view() {
const state = await triage.state();
const items = corpus
.all()
.filter((issue) => issue.createdAt >= state.inboxSince)
.filter((issue) => inInbox(issue, state.inboxSince))
.sort((a, b) => b.number - a.number)
.map((issue) => {
const entry = state.issues[issue.number];
@ -124,6 +124,10 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
return send(res, 200, readFileSync(join(scriptDir, "ui.html"), "utf8"), "text/html; charset=utf-8");
}
if (req.method === "GET" && url.pathname === "/api/state") return send(res, 200, JSON.stringify(await view()));
if (req.method === "GET" && url.pathname === "/api/conversation") {
const items = await triage.transcript(Number(url.searchParams.get("number")));
return items ? send(res, 200, JSON.stringify({ items })) : send(res, 404, '{"error":"no conversation"}');
}
if (req.method === "POST" && url.pathname === "/api/sync") {
void runSync();
return send(res, 202, "{}");

View file

@ -3,7 +3,7 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { type ModelThinkingLevel, StringEnum, Type } from "@earendil-works/pi-ai";
import { type Message, type ModelThinkingLevel, StringEnum, Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
@ -19,7 +19,7 @@ import {
type Tx,
} from "../../packages/durable/src/index.ts";
import { openNodeSqliteStorage } from "../../packages/durable/src/storage/sqlite/node.ts";
import { type Corpus, type Issue, issueStatus } from "./github.ts";
import { type Corpus, type Issue, inInbox, issueStatus } from "./github.ts";
const context = BACKGROUND_CONTEXT;
/** Labels the issue templates set. Other labels and the issue's state are hidden from the agent: they are maintainer decisions. */
@ -58,6 +58,11 @@ export type IssueState = {
type TriageState = { inboxSince: string; issues: Record<string, IssueState> };
/** One transcript line for the UI. A tool call carries its result once the result exists. */
export type TranscriptItem =
| { type: "user" | "text" | "thinking"; text: string }
| { type: "tool"; name: string; args: unknown; output: string | null; isError: boolean };
const StateDoc = defineDoc<TriageState>({
checkpointWhen: (_value, _ops, { deltasSinceBase }) => deltasSinceBase >= 64,
kind: "triage.state",
@ -153,6 +158,42 @@ export class Triage {
return (await this.harness.snapshot(StateDoc, context)) ?? StateDoc.definition.initial();
}
/** The issue's triage conversation without the system prompt, or null when it has none yet. */
async transcript(number: number): Promise<TranscriptItem[] | null> {
const id = (await this.state()).issues[number]?.conversationId;
const conversation = id ? await this.harness.conversation(id as ConversationId, context) : undefined;
if (!conversation) return null;
const { entries } = await conversation.context(context);
const items: TranscriptItem[] = [];
const calls = new Map<string, Extract<TranscriptItem, { type: "tool" }>>();
const text = (content: Message["content"]) =>
typeof content === "string"
? content
: content.map((block) => (block.type === "text" ? block.text : `[${block.type}]`)).join("\n");
for (const message of entries.flatMap((entry) => entry.model ?? [])) {
if (message.role === "user") items.push({ type: "user", text: text(message.content) });
else if (message.role === "toolResult") {
const call = calls.get(message.toolCallId);
if (call) {
call.output = text(message.content);
call.isError = message.isError;
}
} else if (message.role === "assistant") {
for (const block of message.content) {
if (block.type === "text" && block.text.trim()) items.push({ type: "text", text: block.text });
else if (block.type === "thinking" && block.thinking.trim()) {
items.push({ type: "thinking", text: block.thinking });
} else if (block.type === "toolCall") {
const call = { type: "tool" as const, name: block.name, args: block.arguments, output: null, isError: false };
calls.set(block.id, call);
items.push(call);
}
}
}
}
return items;
}
async usageCost(): Promise<number> {
const usage = await this.harness.usage(context);
return Object.values(usage.models).reduce((sum, model) => sum + (model.cost?.total ?? 0), 0);
@ -166,7 +207,7 @@ export class Triage {
async enqueueInbox(): Promise<void> {
const state = await this.state();
for (const issue of this.options.corpus.all()) {
if (issue.createdAt < state.inboxSince) continue;
if (!inInbox(issue, state.inboxSince)) continue;
const current = state.issues[issue.number];
if (current === undefined || current.status === "queued") this.enqueue(issue.number);
}

View file

@ -16,6 +16,7 @@ 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)}
@ -45,6 +46,18 @@ pre{white-space:pre-wrap;word-break:break-word;font:12.5px/1.45 ui-monospace,Men
.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>
@ -57,13 +70,37 @@ pre{white-space:pre-wrap;word-break:break-word;font:12.5px/1.45 ui-monospace,Men
<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}`;
@ -76,9 +113,41 @@ 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();
@ -93,7 +162,8 @@ function visible() {
}
function groups(items) {
if (view !== "inbox") return [["", 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]];
@ -113,7 +183,8 @@ function card(it, idx) {
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>`;
if (r.duplicates.length) body += `<ul class="dups">${r.duplicates.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>`;
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>`;
@ -129,7 +200,8 @@ function card(it, idx) {
<div class="acts">${pri("p0")}${pri("p1")}${pri("none")}
<select data-act="cat" data-n="${it.number}">${r ? "" : "<option></option>"}${cats}</select>
</div>
<details data-n="${it.number}" ${openDetails.has(it.number) ? "open" : ""}><summary>Issue text</summary>
${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>
@ -144,7 +216,7 @@ function render() {
const undecided = data.items.filter((it) => !it.decision?.priority).length;
const q = data.queue;
$("status").innerHTML = [
`${undecided} undecided of ${data.items.length} since ${data.inboxSince.slice(0, 10)}`,
`${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>` : "",
@ -177,11 +249,18 @@ document.addEventListener("click", (e) => {
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) => {
if (e.target.tagName !== "DETAILS") return;
const n = Number(e.target.dataset.n);
e.target.open ? openDetails.add(n) : openDetails.delete(n);
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;
@ -189,14 +268,22 @@ document.querySelectorAll(".tab").forEach((t) => t.onclick = () => {
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.
// Keyboard: j/k select, 0/1/n decide, o open on GitHub, c show the conversation, ? show the shortcuts.
document.addEventListener("keydown", (e) => {
if (!data || e.target.tagName === "SELECT" || e.metaKey || e.ctrlKey) return;
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 });
@ -206,6 +293,8 @@ document.addEventListener("keydown", (e) => {
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>