Review context in the Merge dialog

This commit is contained in:
Ian Gulliver
2026-10-16 09:49:58 -07:00
parent d328aa5e1f
commit 59c2b2cb9c
6 changed files with 322 additions and 21 deletions
+133 -9
View File
@@ -4,7 +4,7 @@ import { markdown } from "./markdown.js";
import { previewURL } from "./preview.js";
import { findingCount, reviewCounts } from "./reviews.js";
import { composerText, endSend, flushDrafts, forgetLostImages, lostImageCount, setComposerImages, setComposerText, startSend } from "./drafts.js";
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, showReview, state, type Topic } from "./state.js";
import { currentSession, findTask, focusTab, focusTask, on, onEach, pickReview, pinSnapshot, setFocus, showReview, state, type Topic } from "./state.js";
import { taskStyle } from "./taskcolor.js";
import { fillTodoLinks } from "./todolink.js";
@@ -1171,15 +1171,139 @@ export async function confirmMerge(taskId: string): Promise<void> {
void showAlert(errorText(err));
return;
}
const title = h("input", { type: "text", value: task.merge_title, required: true, maxLength: 72, pattern: ".*\\S.*", class: "modal-field", ariaLabel: "Commit title" });
const question = [h("p", { class: "modal-text" }, `Merge into ${target} as one commit titled:`), title];
const trailers = mergeTrailers(coAuthors);
if (trailers) {
question.push(h("p", { class: "modal-text" }, trailers));
const title = mergeDrafts.get(taskId) ?? task.merge_title;
mergeDrafts.delete(taskId);
let input: HTMLInputElement | undefined;
const edit = h("button", { type: "button", class: "merge-title-edit", title: "Edit the commit title", ariaLabel: "Edit the commit title" }, "✎");
const row = h("div", { class: "merge-title" }, h("span", { class: "merge-title-text" }, title), edit);
edit.onclick = () => {
const field = h("input", { type: "text", value: title, required: true, maxLength: 72, pattern: ".*\\S.*", ariaLabel: "Commit title" });
field.onkeydown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
row.closest("form")?.querySelector<HTMLButtonElement>(".modal-actions button:last-child")?.click();
}
};
input = field;
replace(row, field);
field.focus();
field.select();
};
const warnings = h("ul", { class: "merge-warnings" });
const question: Node[] = [h("p", { class: "merge-into" }, `Merge into ${target} as:`), row, warnings];
const co = coAuthorsSummary(coAuthors);
if (co) {
question.push(co);
}
await confirmThen(question, "Merge", false, document.querySelector(".merge-button"), async () => {
await post("/api/tasks/{taskId}/merge", { path: { taskId }, body: { merge_title: title.value } });
});
const leave = (go: () => void) => {
mergeDrafts.set(taskId, input?.value ?? title);
warnings.closest("dialog")?.close("cancel");
go();
};
const watch = watchMergeWarnings(taskId, warnings, leave);
await watch.ready;
try {
await confirmThen(question, "Merge", false, document.querySelector(".merge-button"), async () => {
await post("/api/tasks/{taskId}/merge", { path: { taskId }, body: { merge_title: input?.value ?? title } });
});
} finally {
watch.dispose();
}
}
const mergeDrafts = new Map<string, string>();
function watchMergeWarnings(taskId: string, list: HTMLElement, leave: (go: () => void) => void): { ready: Promise<void>; dispose: () => void } {
let reviewItems: HTMLElement[] = [];
let target = 0;
const render = () => {
const questions = state.inbox.filter((i) => i.task_id === taskId && i.kind === "question").length;
const items = [...reviewItems];
if (questions > 0) {
const text = questions === 1 ? "The agent's open question will be dismissed" : `The agent's ${questions} open questions will be dismissed`;
items.push(mergeWarning(text, () => leave(() => focusTask(taskId, "inbox"))));
}
replace(list, ...items);
list.hidden = items.length === 0;
};
const load = async () => {
const started = ++target;
let items: HTMLElement[];
try {
items = await reviewWarnings(taskId, leave);
} catch (err) {
items = [mergeWarning("Review status unavailable", undefined, errorText(err))];
}
if (started === target) {
reviewItems = items;
render();
}
};
const offs = [
on("event", (detail) => {
const ev = detail as ByreEvent;
if (ev.task_id === taskId && (ev.kind === "snapshot.created" || ev.kind.startsWith("review."))) {
void load();
}
}),
on("inbox", render),
];
return {
ready: load(),
dispose: () => {
target++;
for (const off of offs) {
off();
}
},
};
}
async function reviewWarnings(taskId: string, leave: (go: () => void) => void): Promise<HTMLElement[]> {
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")];
}
const items: HTMLElement[] = [];
const open = () =>
leave(() => {
focusTask(taskId, "reviews");
pickReview(latest.n);
});
const r = reviews.find((r) => r.snapshot_n === latest.n);
if (!r) {
items.push(mergeWarning("The latest snapshot hasn't been reviewed", open));
} else if (r.state === "running") {
items.push(mergeWarning("Review of the latest snapshot is still running", open));
} else if (r.state === "failed") {
items.push(mergeWarning("Review of the latest snapshot failed", open));
} else if (findingCount(r) > 0) {
items.push(mergeWarning(`Review of the latest snapshot found ${reviewCounts(r)}`, open));
}
const changes = await get("/api/tasks/{taskId}/changes", { path: { taskId }, query: { since: latest.commit_sha } });
if (changes.files.length > 0) {
items.push(mergeWarning("Changes since the last snapshot haven't been reviewed"));
}
return items;
}
function mergeWarning(text: string, open?: () => void, detail?: string): HTMLElement {
if (!open) {
return h("li", { class: "merge-warning", title: detail ?? "" }, `⚠ ${text}`);
}
const link = h("button", { type: "button", class: "merge-warning-link" }, text);
link.onclick = open;
return h("li", { class: "merge-warning" }, "⚠ ", link);
}
function coAuthorsSummary(coAuthors: Participant[]): HTMLElement | null {
if (coAuthors.length === 0) {
return null;
}
const missing = coAuthors.filter((p) => !p.git_name || !p.git_email).length;
const label = `${coAuthors.length} co-author${coAuthors.length === 1 ? "" : "s"}${missing > 0 ? ` (${missing} without git identity)` : ""}`;
return h("details", { class: "merge-coauthors" }, h("summary", {}, label), h("p", { class: "modal-text" }, mergeTrailers(coAuthors)));
}
function mergeTrailers(coAuthors: Participant[]): string {