Add change summaries, inline diff hunks and screenshots to reviews

This commit is contained in:
Ian Gulliver
2026-10-15 19:02:33 -07:00
parent ed40d91a42
commit a2067c2ba0
14 changed files with 442 additions and 32 deletions
+112 -1
View File
@@ -66,14 +66,19 @@ function reviewResult(r: Review, taskId: string | undefined): HTMLElement {
return h(
"div",
{ class: "review-result" },
typeof res["change"] === "string" && res["change"] ? markdown(res["change"], "md review-change", md) : null,
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 where = typeof f["path"] === "string" && f["path"] ? `${f["path"]}${typeof f["line"] === "number" && f["line"] > 0 ? `:${f["line"]}` : ""}` : "";
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}` },
@@ -86,7 +91,9 @@ function reviewResult(r: Review, taskId: string | undefined): HTMLElement {
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"]),
);
}),
)
@@ -94,6 +101,110 @@ function reviewResult(r: Review, taskId: string | undefined): HTMLElement {
);
}
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 () => {