Add change summaries, inline diff hunks and screenshots to reviews
This commit is contained in:
Vendored
+93
-3
@@ -40,14 +40,104 @@ function reviewResult(r, taskId) {
|
||||
const res = (r.result ?? {});
|
||||
const findings = findingsOf(r);
|
||||
const md = { taskId };
|
||||
return h("div", { class: "review-result" }, markdown(String(res["summary"] ?? ""), "md review-summary", md), findings.length > 0
|
||||
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"]}` : ""}` : "";
|
||||
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), markdown(String(f["detail"] ?? ""), "md finding-detail", md));
|
||||
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) {
|
||||
if (!Array.isArray(v) || v.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return h("div", { class: "review-images" }, ...v.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();
|
||||
const stepPatches = new Map();
|
||||
function snapshotPatch(taskId, n, path) {
|
||||
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) => {
|
||||
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;
|
||||
}
|
||||
function linesAround(patch, line, end) {
|
||||
const lines = [];
|
||||
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, n, path, line, end) {
|
||||
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 = [];
|
||||
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) => replace(box, h("div", { class: "hint" }, `diff not available: ${errorText(err)}`)));
|
||||
return box;
|
||||
}
|
||||
function fixButton(taskId, r, f, where) {
|
||||
const button = h("button", { type: "button", class: "finding-fix", title: "Send this finding to the task's agent to fix" }, "fix");
|
||||
button.onclick = async () => {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+112
-1
@@ -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 () => {
|
||||
|
||||
@@ -1721,6 +1721,65 @@ pre.file-text .line::before {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.review-change {
|
||||
margin: 8px 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.finding-diff {
|
||||
margin-top: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.finding-diff .hint {
|
||||
padding: 4px 10px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
pre.diff .lineno {
|
||||
display: inline-block;
|
||||
min-width: 4ch;
|
||||
margin-right: 8px;
|
||||
text-align: right;
|
||||
color: var(--muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
pre.diff .mark {
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.review-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.review-images figure {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.review-images img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 360px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.review-images figcaption {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.finding-fix {
|
||||
margin-left: auto;
|
||||
padding: 0 8px;
|
||||
|
||||
Reference in New Issue
Block a user