Files
byre/web/src/reviews.ts
T
2026-10-16 08:38:42 -07:00

328 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { get, post, type ByreEvent, type Schemas } from "./api.js";
import { ago, errorText, h, replace, showAlert } from "./dom.js";
import { markdown } from "./markdown.js";
import { focusTask, onEach, pickReview, state } from "./state.js";
type Review = Schemas["Review"];
const severities = ["critical", "high", "medium", "low", "info"];
export function isReviewEvent(ev: ByreEvent): boolean {
return ev.kind.startsWith("review.");
}
function findingsOf(r: Review): Record<string, unknown>[] {
const res = (r.result ?? {}) as Record<string, unknown>;
return Array.isArray(res["findings"]) ? (res["findings"] as Record<string, unknown>[]) : [];
}
export function findingCount(r: Review): number {
return r.state === "done" ? findingsOf(r).length : 0;
}
export function latestFindingCount(events: ByreEvent[]): number {
const finished = events.filter((ev) => ev.kind === "review.finished").map((ev) => ev.payload as unknown as Review);
const top = finished.reduce<Review | undefined>((best, r) => (!best || r.snapshot_n >= best.snapshot_n ? r : best), undefined);
return top ? findingCount(top) : 0;
}
export function reviewCounts(r: Review): string {
const findings = findingsOf(r);
const parts = severities.map((s) => [s, findings.filter((f) => f["severity"] === s).length] as const).filter(([, n]) => n > 0);
return parts.length > 0 ? parts.map(([s, n]) => `${n} ${s}`).join(", ") : "no findings";
}
function stateText(r: Review): string {
switch (r.state) {
case "running":
return `reviewing on ${r.vendor} (${r.model})…`;
case "done":
return reviewCounts(r);
case "failed":
return "failed";
case "skipped":
return "skipped: no changes";
}
}
export function reviewCard(r: Review, taskId: string | undefined): HTMLElement {
return h(
"article",
{ class: `review ${r.state}` },
h(
"header",
{},
h("span", { class: "review-snapshot" }, `snapshot #${r.snapshot_n}`),
h("span", { class: `review-state ${r.state}` }, stateText(r)),
h("time", { dateTime: r.created_at, title: r.created_at }, ago(r.created_at)),
),
r.state === "failed" ? h("p", { class: "error-text" }, r.error) : null,
r.state === "done" ? reviewResult(r, taskId) : null,
);
}
function reviewResult(r: Review, taskId: string | undefined): HTMLElement {
const res = (r.result ?? {}) as Record<string, unknown>;
const findings = findingsOf(r);
const md = { taskId };
return h(
"div",
{ class: "review-result" },
markdown(String(res["summary"] ?? ""), "md review-summary", md),
reviewImages(res["images"]),
findings.length > 0
? h(
"ol",
{ class: "findings" },
...findings.map((f) => {
const severity = String(f["severity"] ?? "");
const path = typeof f["path"] === "string" ? f["path"] : "";
const line = typeof f["line"] === "number" && f["line"] > 0 ? f["line"] : 0;
const end = typeof f["end_line"] === "number" && f["end_line"] > line ? f["end_line"] : line;
const where = path ? `${path}${line ? `:${line}${end > line ? `–${end}` : ""}` : ""}` : "";
return h(
"li",
{ class: `finding ${severity}` },
h(
"div",
{ class: "finding-head" },
h("span", { class: `severity ${severity}` }, severity),
typeof f["category"] === "string" && f["category"] ? h("span", { class: "finding-category" }, f["category"]) : null,
h("span", { class: "finding-title" }, String(f["title"] ?? "")),
where ? h("code", { class: "finding-where" }, where) : null,
taskId ? fixButton(taskId, r, f, where) : null,
),
taskId && path && line ? findingDiff(taskId, r.snapshot_n, path, line, end) : null,
markdown(String(f["detail"] ?? ""), "md finding-detail", md),
reviewImages(f["images"]),
);
}),
)
: null,
);
}
function reviewImages(v: unknown): HTMLElement | null {
if (!Array.isArray(v) || v.length === 0) {
return null;
}
return h(
"div",
{ class: "review-images" },
...(v as Record<string, unknown>[]).map((img) => {
const src = `/api/blobs/${encodeURIComponent(String(img["blob"] ?? ""))}`;
const caption = typeof img["caption"] === "string" ? img["caption"] : "";
return h(
"figure",
{},
h("a", { href: src, target: "_blank", rel: "noopener" }, h("img", { src, alt: caption || "screenshot", loading: "lazy" })),
caption ? h("figcaption", {}, caption) : null,
);
}),
);
}
const stepChanges = new Map<string, Promise<Schemas["TaskChanges"]>>();
const stepPatches = new Map<string, Promise<string | undefined>>();
function snapshotPatch(taskId: string, n: number, path: string): Promise<string | undefined> {
const key = `${taskId}/${n}/${path}`;
const cached = stepPatches.get(key);
if (cached) {
return cached;
}
const fetched = (async () => {
const ck = `${taskId}/${n}`;
let pending = stepChanges.get(ck);
if (!pending) {
pending = get("/api/tasks/{taskId}/changes", { path: { taskId }, query: { snapshot: n, step: true } });
stepChanges.set(ck, pending);
}
const changes = await pending.catch((err: unknown) => {
stepChanges.delete(ck);
throw err;
});
const f = changes.files.find((c) => c.path === path);
if (!f || f.binary) {
return undefined;
}
const d = await get("/api/tasks/{taskId}/diff", { path: { taskId }, query: { base: changes.base, tree: changes.tree, path: f.path, old_path: f.old_path } });
return d.patch;
})();
stepPatches.set(key, fetched);
fetched.catch(() => stepPatches.delete(key));
return fetched;
}
type DiffLine = { kind: "add" | "del" | "ctx"; text: string; at: number };
function linesAround(patch: string, line: number, end: number): DiffLine[] {
const lines: DiffLine[] = [];
let n = 0;
let inHunk = false;
for (const l of patch.split("\n")) {
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l);
if (m) {
n = Number(m[1]);
inHunk = true;
continue;
}
if (!inHunk) {
continue;
}
if (l.startsWith("+")) {
lines.push({ kind: "add", text: l, at: n++ });
} else if (l.startsWith("-")) {
lines.push({ kind: "del", text: l, at: n });
} else if (l.startsWith(" ")) {
lines.push({ kind: "ctx", text: l, at: n++ });
}
}
return lines.filter((d) => d.at >= line - 3 && d.at <= end + 3);
}
function findingDiff(taskId: string, n: number, path: string, line: number, end: number): HTMLElement {
const box = h("div", { class: "finding-diff" });
snapshotPatch(taskId, n, path).then(
(patch) => {
const lines = patch === undefined ? [] : linesAround(patch, line, end);
if (lines.length === 0) {
box.remove();
return;
}
const rows: HTMLElement[] = [];
lines.forEach((d, i) => {
const prev = lines[i - 1];
if (prev && d.kind !== "del" && prev.kind !== "del" && d.at > prev.at + 1) {
rows.push(h("span", { class: "line hunk" }, "⋯"));
}
const mark = d.kind !== "del" && d.at >= line && d.at <= end;
rows.push(h("span", { class: `line ${d.kind}${mark ? " mark" : ""}` }, h("span", { class: "lineno" }, d.kind === "del" ? "" : String(d.at)), d.text || " "));
});
replace(box, h("pre", { class: "diff" }, ...rows));
},
(err: unknown) => replace(box, h("div", { class: "hint" }, `diff not available: ${errorText(err)}`)),
);
return box;
}
function fixButton(taskId: string, r: Review, f: Record<string, unknown>, where: string): HTMLElement {
const button = h("button", { type: "button", class: "finding-fix", title: "Send this finding to the task's agent to fix" }, "fix");
button.onclick = async () => {
const text = `Fix this finding from the review of snapshot #${r.snapshot_n}:\n\n**[${String(f["severity"] ?? "")}] ${String(f["title"] ?? "")}**${where ? ` (\`${where}\`)` : ""}\n\n${String(f["detail"] ?? "")}`;
button.disabled = true;
try {
const sessions = await get("/api/tasks/{taskId}/sessions", { path: { taskId } });
const live = sessions.findLast((s) => s.state !== "completed");
if (live) {
await post("/api/sessions/{sessionId}/messages", { path: { sessionId: live.id }, body: { text } });
} else {
await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: text } });
}
replace(button, "sent");
focusTask(taskId);
} catch (err) {
button.disabled = false;
void showAlert(errorText(err));
}
};
return button;
}
export function mountReviews(root: HTMLElement): () => void {
const head = h("header", { class: "reviews-header" });
const body = h("div", { class: "review-list" });
let reviews: Review[] | undefined;
let error = "";
let target = 0;
let disposed = false;
const load = async () => {
const taskId = state.selectedTaskId;
if (!taskId) {
render();
return;
}
const started = ++target;
try {
const fresh = await get("/api/tasks/{taskId}/reviews", { path: { taskId } });
if (started === target) {
reviews = fresh;
error = "";
}
} catch (err) {
if (started === target) {
reviews = undefined;
error = errorText(err);
}
}
if (!disposed && started === target) {
render();
}
};
const render = () => {
const taskId = state.selectedTaskId;
replace(head);
head.hidden = true;
if (!taskId) {
replace(body, h("div", { class: "empty" }, "No task selected."));
return;
}
if (error) {
replace(body, h("div", { class: "empty error-text" }, error));
return;
}
if (!reviews) {
replace(body, h("div", { class: "empty" }, "Loading…"));
return;
}
if (reviews.length === 0) {
replace(body, h("div", { class: "empty" }, "No reviews yet. Each new snapshot of the task is reviewed by an agent from the other vendor."));
return;
}
const n = state.reviewed ?? reviews.at(-1)!.snapshot_n;
const shown = reviews.findLast((r) => r.snapshot_n === n);
const ns = [...new Set([n, ...reviews.map((r) => r.snapshot_n)])].sort((a, b) => b - a);
const picker = h("select", { title: "Snapshot whose review to show" }, ...ns.map((s) => h("option", { value: String(s) }, `snapshot #${s}`)));
picker.value = String(n);
picker.onchange = () => pickReview(Number(picker.value));
const step = (to: number, label: string) => {
const b = h("button", { type: "button", class: "snap-step", disabled: !ns.includes(to) }, label);
b.onclick = () => pickReview(to);
return b;
};
const next = ns.filter((s) => s > n).at(-1);
const prev = ns.find((s) => s < n);
head.hidden = false;
replace(head, picker, step(prev ?? n - 1, `← #${prev ?? n - 1}`), step(next ?? n + 1, `#${next ?? n + 1} →`));
replace(body, shown ? reviewCard(shown, taskId) : h("div", { class: "empty" }, `No review of snapshot #${n} yet.`));
};
const offs = [
onEach(["selection"], () => {
target++;
reviews = undefined;
error = "";
render();
void load();
}),
onEach(["transcript"], (fresh) => {
if ((fresh as ByreEvent[]).some(isReviewEvent)) {
void load();
}
}),
onEach(["review"], render),
];
replace(root, h("div", { class: "reviews-view" }, head, body));
render();
void load();
return () => {
disposed = true;
for (const off of offs) {
off();
}
};
}