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
+48 -2
View File
@@ -388,7 +388,7 @@ export interface paths {
};
get?: never;
put?: never;
/** @description Sets the task's merge_title to the confirmed title, then starts the same agent turn as merge-in, without its invitation to revise the title. When that turn ends normally, byre squashes the task's worktree into one commit titled with the task's merge_title on the project's default branch, authored by the caller with a Co-authored-by trailer for each of the task's co-authors, pushes it to origin, fast-forwards the project's checkout to it, then removes the worktree and branch and marks the task merged. While another task in the project is merging, the merge is held instead (merge_queued_at is set) and starts when no merge is in progress in the project and the task's session is idle, oldest first. The title is fixed from here until the task closes. Refused while a turn is running, or the merge is already held. With target parent, for a subtask only, the subtask's branch is merged into its parent task's branch instead: the parent's agent is asked to merge it (queued if it is mid-turn), and the subtask closes as merged once its branch is in the parent's HEAD at the end of one of the parent's turns (merging_into_parent is set until then). Refused while the subtask's turn is running or it is already merging into its parent. Either target is refused while the task has active subtasks of its own. */
/** @description Sets the task's merge_title to the confirmed title, then starts the same agent turn as merge-in, without its invitation to revise the title. When that turn ends normally, byre squashes the task's worktree into one commit titled with the task's merge_title on the project's default branch, authored by the caller with a Co-authored-by trailer for each of the task's co-authors, pushes it to origin, fast-forwards the project's checkout to it, then removes the worktree and branch and marks the task merged. While another task in the project is merging, the merge is held instead (merge_queued_at is set) and starts when no merge is in progress in the project and the task's session is idle, oldest first. The title is fixed from here until the task closes. Refused while a turn is running, or the merge is already held. With target parent, for a subtask only, the subtask's branch is merged into its parent task's branch instead: the parent's agent is asked to merge it (queued if it is mid-turn), and the subtask closes as merged once its branch is in the parent's HEAD at the end of one of the parent's turns (merging_into_parent is set until then). Refused while the subtask's turn is running or it is already merging into its parent. Either target is refused while the task has active subtasks of its own. With after_review, nothing starts yet: the request is recorded (merge_after_review is set) and made, as this caller and with this target, when the review of the task's latest snapshot finishes with no findings, that snapshot is still the latest and no turn is running; otherwise it is dropped with a transcript note. Refused (409) unless that review is running. */
post: operations["mergeTask"];
delete?: never;
options?: never;
@@ -396,6 +396,25 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/merge-after-review": {
parameters: {
query?: never;
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** @description Withdraws a Merge with after_review that is still waiting for its review; nothing is merged. */
delete: operations["cancelMergeAfterReview"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/merge-in": {
parameters: {
query?: never;
@@ -1012,6 +1031,8 @@ export interface components {
parent_task_id?: string;
/** @description Whether a merge of this subtask into its parent was requested and the parent hasn't merged it yet. */
merging_into_parent: boolean;
/** @description Whether a Merge with after_review is waiting for the running review of the latest snapshot to finish. */
merge_after_review: boolean;
/**
* Format: int64
* @description The seq of the task's latest turn.ended event with summary: true; 0 when it has none.
@@ -2129,11 +2150,13 @@ export interface operations {
* @enum {string}
*/
target?: "main" | "parent";
/** @description Merge once the running review of the latest snapshot finishes with no findings, instead of now. */
after_review?: boolean;
};
};
};
responses: {
/** @description The merge was started or held. */
/** @description The merge was started, held, or set to start after the review. */
202: {
headers: {
[name: string]: unknown;
@@ -2147,6 +2170,29 @@ export interface operations {
409: components["responses"]["Error"];
};
};
cancelMergeAfterReview: {
parameters: {
query?: never;
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The request was withdrawn. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Task"];
};
};
404: components["responses"]["Error"];
};
};
mergeIn: {
parameters: {
query?: never;
+8 -2
View File
@@ -79,8 +79,10 @@ export function errorText(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function modal(content: string | Node[], buttons: { label: string; value: string; class?: string }[], anchor: Element | null = null): Promise<string> {
const actions = buttons.map((b) => h("button", { type: "submit", value: b.value, class: b.class ?? "", formNoValidate: b.value === "cancel" }, b.label));
type ModalButton = { label: string; value: string; class?: string; title?: string };
function modal(content: string | Node[], buttons: ModalButton[], anchor: Element | null = null): Promise<string> {
const actions = buttons.map((b) => h("button", { type: "submit", value: b.value, class: b.class ?? "", title: b.title ?? "", formNoValidate: b.value === "cancel" }, b.label));
const body = typeof content === "string" ? [h("p", { class: "modal-text" }, content)] : content;
const dialog = h("dialog", { class: "modal" }, h("form", { method: "dialog" }, ...body, h("div", { class: "modal-actions" }, ...actions)));
for (const input of dialog.querySelectorAll("input")) {
@@ -129,3 +131,7 @@ export async function showConfirm(content: string | Node[], action: string, dang
);
return value === "ok";
}
export async function showChoice(content: string | Node[], choices: ModalButton[], anchor: Element | null = null): Promise<string> {
return modal(content, [{ label: "Cancel", value: "cancel" }, ...choices], anchor);
}
+6
View File
@@ -111,6 +111,9 @@ export function taskState(task: Task): TaskState {
if (task.merge_queued_at !== undefined) {
return { kind: "held", label: "Merge held", title: "Waiting for the merge in progress in this project to finish" };
}
if (task.merge_after_review) {
return { kind: "held", label: "Merge after review", title: "Merges when the running review finishes with no findings" };
}
if (open.length > 0) {
return { kind: "error", label: "Error", title: "The agent's session failed or hit a rate limit; see the inbox" };
}
@@ -498,6 +501,9 @@ export function handleEvent(ev: ByreEvent): void {
case "task.merge_title":
case "task.merge_queued":
case "task.merge_unqueued":
case "task.merge_after_review":
case "task.merge_after_review_ended":
case "task.merge_after_review_cancelled":
case "task.parent_merge_started":
case "task.parent_merge_stopped":
case "task.merge_started":
+77 -14
View File
@@ -1,5 +1,5 @@
import { get, post, type AgentSession, type ByreEvent, type Participant, type Schemas, type Snapshot } from "./api.js";
import { ago, copyable, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
import { del, get, post, type AgentSession, type ByreEvent, type Participant, type Schemas, type Snapshot } 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";
@@ -172,11 +172,13 @@ function taskHead(rerender: () => void): Head {
? "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
: 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" : "Merge",
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}`);
@@ -614,6 +616,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
}
}
fillTodoLinks(transcript, models[0]!);
syncMergeAfterReview(transcript);
pin();
jump();
};
@@ -630,6 +633,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
syncRunning();
fillTodoLinks(box, models[0]!);
earlier.after(box);
syncMergeAfterReview(transcript);
transcript.scrollTop = transcript.scrollHeight - fromBottom;
};
@@ -707,7 +711,10 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
loading.remove();
watchTop();
}),
onEach(["tasks", "sessions"], () => fillTodoLinks(transcript, models[0]!)),
onEach(["tasks", "sessions"], () => {
fillTodoLinks(transcript, models[0]!);
syncMergeAfterReview(transcript);
}),
on("jump", jump),
];
@@ -1273,6 +1280,28 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
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);
@@ -1302,6 +1331,14 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
return null;
}
function syncMergeAfterReview(root: ParentNode): void {
const buttons = [...root.querySelectorAll<HTMLButtonElement>(".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: string): Node {
const task = findTask(id);
if (task?.status !== "active") {
@@ -1427,11 +1464,33 @@ export async function confirmMerge(taskId: string): Promise<void> {
};
const watch = watchMergeWarnings(taskId, warnings, leave);
await watch.ready;
const anchor = document.querySelector(".merge-button");
const merge = async (afterReview: boolean) => {
const body = toParent ? { target: "parent" as const } : { 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" as const } : { 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();
}
@@ -1439,8 +1498,9 @@ export async function confirmMerge(taskId: string): Promise<void> {
const mergeDrafts = new Map<string, string>();
function watchMergeWarnings(taskId: string, list: HTMLElement, leave: (go: () => void) => void): { ready: Promise<void>; dispose: () => void } {
function watchMergeWarnings(taskId: string, list: HTMLElement, leave: (go: () => void) => void): { ready: Promise<void>; reviewRunning: () => boolean; dispose: () => void } {
let reviewItems: HTMLElement[] = [];
let running = false;
let target = 0;
const render = () => {
const questions = state.inbox.filter((i) => i.task_id === taskId && i.kind === "question").length;
@@ -1455,13 +1515,15 @@ function watchMergeWarnings(taskId: string, list: HTMLElement, leave: (go: () =>
const load = async () => {
const started = ++target;
let items: HTMLElement[];
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();
}
};
@@ -1476,6 +1538,7 @@ function watchMergeWarnings(taskId: string, list: HTMLElement, leave: (go: () =>
];
return {
ready: load(),
reviewRunning: () => running,
dispose: () => {
target++;
for (const off of offs) {
@@ -1485,11 +1548,11 @@ function watchMergeWarnings(taskId: string, list: HTMLElement, leave: (go: () =>
};
}
async function reviewWarnings(taskId: string, leave: (go: () => void) => void): Promise<HTMLElement[]> {
async function reviewWarnings(taskId: string, leave: (go: () => void) => void): Promise<{ items: HTMLElement[]; running: boolean }> {
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: HTMLElement[] = [];
const open = () =>
@@ -1511,7 +1574,7 @@ async function reviewWarnings(taskId: string, leave: (go: () => void) => void):
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: string, open?: () => void, detail?: string): HTMLElement {
+1 -1
View File
@@ -64,5 +64,5 @@ function fill(el: HTMLElement, model: string): void {
startButton.onclick = () => void start(false);
const discussButton = h("button", { disabled: busy, title: "Start a task that discusses it with you in chat before building anything" }, "discuss");
discussButton.onclick = () => void start(true);
replace(action, startButton, h("span", { class: "todo-sep" }, " · "), discussButton);
replace(action, startButton, discussButton);
}