Offer "After review" in the Merge dialog while a review runs

This commit is contained in:
Ian Gulliver
2026-10-16 22:34:14 -07:00
parent c2d0ff77d7
commit b28968a0bc
25 changed files with 835 additions and 292 deletions
+72 -14
View File
@@ -1,5 +1,5 @@
import { get, post } from "./api.js";
import { ago, copyable, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
import { del, get, post } from "./api.js";
import { ago, copyable, errorText, h, replace, showAlert, showChoice, showConfirm } from "./dom.js";
import { markdown } from "./markdown.js";
import { previewURL } from "./preview.js";
import { findingCount, reviewCounts, severityCounts, unignoredFindings } from "./reviews.js";
@@ -103,10 +103,12 @@ function taskHead(rerender) {
? "Waiting for the parent task's agent to merge this subtask"
: held
? "Waiting for the merge in progress in this project to finish"
: task.parent_task_id !== undefined
? `Merge this subtask into its parent task's branch, or into ${target} (⌘S)`
: `The agent merges ${target} in, then byre squashes into one commit on ${target} and pushes it to origin; held while another task in the project is merging (⌘S)`,
}, intoParent ? "Merging into parent" : held ? "Merge held" : "Merge");
: task.merge_after_review
? "Merges when the running review finishes with no findings; open to merge now instead (⌘S)"
: task.parent_task_id !== undefined
? `Merge this subtask into its parent task's branch, or into ${target} (⌘S)`
: `The agent merges ${target} in, then byre squashes into one commit on ${target} and pushes it to origin; held while another task in the project is merging (⌘S)`,
}, intoParent ? "Merging into parent" : held ? "Merge held" : task.merge_after_review ? "Merge after review" : "Merge");
merge.onclick = () => confirmMerge(task.id);
const mergeIn = h("button", { disabled: busy, title: `Ask the agent to bring ${target} into this branch and resolve any conflicts` }, `Sync from ${target}`);
mergeIn.onclick = async () => {
@@ -523,6 +525,7 @@ export function mountChat(root, src, makeHead) {
}
}
fillTodoLinks(transcript, models[0]);
syncMergeAfterReview(transcript);
pin();
jump();
};
@@ -538,6 +541,7 @@ export function mountChat(root, src, makeHead) {
syncRunning();
fillTodoLinks(box, models[0]);
earlier.after(box);
syncMergeAfterReview(transcript);
transcript.scrollTop = transcript.scrollHeight - fromBottom;
};
let disposed = false;
@@ -610,7 +614,10 @@ export function mountChat(root, src, makeHead) {
loading.remove();
watchTop();
}),
onEach(["tasks", "sessions"], () => fillTodoLinks(transcript, models[0])),
onEach(["tasks", "sessions"], () => {
fillTodoLinks(transcript, models[0]);
syncMergeAfterReview(transcript);
}),
on("jump", jump),
];
replace(root, h("div", { class: "transcript-view" }, head, transcript, composer));
@@ -1085,6 +1092,27 @@ function renderEvent(ev, ctx) {
return h("div", { class: "note" }, `${ev.actor} asked to merge; held until the merge in progress in this project is done `, when);
case "task.merge_stopped":
return h("div", { class: "note" }, `${ev.actor} stopped the merge for review: ${String(p["reason"])} `, when);
case "task.merge_after_review": {
const taskId = String(p["id"]);
const cancel = h("button", { type: "button", class: "merge-after-review-cancel", hidden: true, title: "Withdraw this request; nothing is merged", data: { taskId } }, "Cancel");
cancel.onclick = async () => {
cancel.disabled = true;
try {
await del("/api/tasks/{taskId}/merge-after-review", { path: { taskId } });
}
catch (err) {
void showAlert(errorText(err));
}
finally {
cancel.disabled = false;
}
};
return h("div", { class: "note" }, `${ev.actor} asked to merge once the review of snapshot #${String(p["snapshot_n"])} finishes with no findings `, when, cancel);
}
case "task.merge_after_review_cancelled":
return h("div", { class: "note" }, `${ev.actor} cancelled the merge after review `, when);
case "task.merge_after_review_dropped":
return h("div", { class: "note" }, `didn't merge after the review: ${String(p["reason"])} `, when);
case "preview.exposed": {
const url = previewURL(Number(p["port"]));
return h("div", { class: "note" }, `${ev.actor} exposed "${String(p["name"])}" at `, h("a", { href: url, target: "_blank", rel: "noopener" }, url), " ", when);
@@ -1103,6 +1131,13 @@ function renderEvent(ev, ctx) {
}
return null;
}
function syncMergeAfterReview(root) {
const buttons = [...root.querySelectorAll(".merge-after-review-cancel")];
const last = buttons.at(-1);
for (const b of buttons) {
b.hidden = b !== last || findTask(b.dataset["taskId"] ?? "")?.merge_after_review !== true;
}
}
function taskLink(id) {
const task = findTask(id);
if (task?.status !== "active") {
@@ -1212,11 +1247,30 @@ export async function confirmMerge(taskId) {
};
const watch = watchMergeWarnings(taskId, warnings, leave);
await watch.ready;
const anchor = document.querySelector(".merge-button");
const merge = async (afterReview) => {
const body = toParent ? { target: "parent" } : { merge_title: input?.value ?? title };
try {
await post("/api/tasks/{taskId}/merge", { path: { taskId }, body: afterReview ? { ...body, after_review: true } : body });
}
catch (err) {
await showAlert(errorText(err));
}
};
try {
await confirmThen(question, "Merge", false, document.querySelector(".merge-button"), async () => {
const body = toParent ? { target: "parent" } : { merge_title: input?.value ?? title };
await post("/api/tasks/{taskId}/merge", { path: { taskId }, body });
});
if (!watch.reviewRunning()) {
if (await showConfirm(question, "Merge", false, anchor)) {
await merge(false);
}
return;
}
const choice = await showChoice(question, [
{ label: "Merge now", value: "now" },
{ label: "After review", value: "after", class: "primary", title: "Merge once the running review finishes with no findings" },
], anchor);
if (choice === "now" || choice === "after") {
await merge(choice === "after");
}
}
finally {
watch.dispose();
@@ -1225,6 +1279,7 @@ export async function confirmMerge(taskId) {
const mergeDrafts = new Map();
function watchMergeWarnings(taskId, list, leave) {
let reviewItems = [];
let running = false;
let target = 0;
const render = () => {
const questions = state.inbox.filter((i) => i.task_id === taskId && i.kind === "question").length;
@@ -1239,14 +1294,16 @@ function watchMergeWarnings(taskId, list, leave) {
const load = async () => {
const started = ++target;
let items;
let isRunning = false;
try {
items = await reviewWarnings(taskId, leave);
({ items, running: isRunning } = await reviewWarnings(taskId, leave));
}
catch (err) {
items = [mergeWarning("Review status unavailable", undefined, errorText(err))];
}
if (started === target) {
reviewItems = items;
running = isRunning;
render();
}
};
@@ -1261,6 +1318,7 @@ function watchMergeWarnings(taskId, list, leave) {
];
return {
ready: load(),
reviewRunning: () => running,
dispose: () => {
target++;
for (const off of offs) {
@@ -1273,7 +1331,7 @@ async function reviewWarnings(taskId, leave) {
const [snaps, reviews] = await Promise.all([get("/api/tasks/{taskId}/snapshots", { path: { taskId } }), get("/api/tasks/{taskId}/reviews", { path: { taskId } })]);
const latest = snaps.at(-1);
if (!latest) {
return [mergeWarning("These changes haven't been reviewed")];
return { items: [mergeWarning("These changes haven't been reviewed")], running: false };
}
const items = [];
const open = () => leave(() => {
@@ -1297,7 +1355,7 @@ async function reviewWarnings(taskId, leave) {
if (changes.files.length > 0) {
items.push(mergeWarning("Changes since the last snapshot haven't been reviewed"));
}
return items;
return { items, running: r?.state === "running" };
}
function mergeWarning(text, open, detail) {
if (!open) {