Let task agents create and manage subtasks

This commit is contained in:
Ian Gulliver
2026-10-16 10:36:38 -07:00
parent fd63a0dfbc
commit 0f2415570c
30 changed files with 1067 additions and 287 deletions
+87 -30
View File
@@ -155,16 +155,21 @@ function taskHead(rerender: () => void): Head {
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
const busy = sess?.state === "running";
const held = task.merge_queued_at !== undefined;
const intoParent = task.merging_into_parent;
const merge = h(
"button",
{
class: "merge-button",
disabled: busy || held || unmerged?.taskId !== task.id || !unmerged.has,
title: held
? "Waiting for the merge in progress in this project to finish"
: `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)`,
disabled: busy || held || intoParent || unmerged?.taskId !== task.id || !unmerged.has,
title: intoParent
? "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)`,
},
held ? "Merge held" : "Merge",
intoParent ? "Merging into parent" : held ? "Merge held" : "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}`);
@@ -1068,30 +1073,28 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
}
case "task.created": {
if (typeof p["forked_from"] !== "string") {
return null;
if (typeof p["forked_from"] === "string") {
return h(
"div",
{ class: "note" },
`${ev.actor} forked this task from `,
taskLink(p["forked_from"]),
` at snapshot #${String(p["forked_at"])}; its agent continues that task's conversation as it stood there `,
when,
);
}
const src = findTask(p["forked_from"]);
const title = src?.title ?? p["forked_from"];
const link =
src?.status === "active"
? h(
"a",
{
href: `#/task/${src.id}`,
class: "task-link",
style: taskStyle(src.id),
onclick: (e: MouseEvent) => {
e.preventDefault();
focusTask(src.id);
},
},
title,
)
: title;
return h("div", { class: "note" }, `${ev.actor} forked this task from `, link, ` at snapshot #${String(p["forked_at"])}; its agent continues that task's conversation as it stood there `, when);
if (typeof p["parent_task_id"] === "string") {
return h("div", { class: "note" }, "subtask of ", taskLink(p["parent_task_id"]), ", created by its agent ", when);
}
return null;
}
case "task.parent_merge_started":
return h("div", { class: "note" }, `${ev.actor} asked `, taskLink(String(p["parent_task_id"])), "'s agent to merge this subtask into its branch ", when);
case "task.parent_merge_stopped":
return h("div", { class: "note error" }, "the merge into the parent task didn't start ", when);
case "task.merge_title":
return h("div", { class: "note" }, `${ev.actor} set the merge commit title to "${String(p["merge_title"])}" `, when);
@@ -1113,6 +1116,9 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
}
case "task.merged":
if (typeof p["parent_task_id"] === "string") {
return h("div", { class: "note" }, `${ev.actor} merged the task into `, taskLink(p["parent_task_id"]), `, now at ${String(p["commit"]).slice(0, 7)} `, when);
}
return h(
"div",
{ class: "note" },
@@ -1133,6 +1139,26 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
return null;
}
function taskLink(id: string): Node {
const task = findTask(id);
if (task?.status !== "active") {
return document.createTextNode(task?.title ?? id);
}
return h(
"a",
{
href: `#/task/${task.id}`,
class: "task-link",
style: taskStyle(task.id),
onclick: (e: MouseEvent) => {
e.preventDefault();
focusTask(task.id);
},
},
task.title,
);
}
function messageImages(ids: unknown): HTMLElement | null {
if (!Array.isArray(ids) || ids.length === 0) {
return null;
@@ -1161,7 +1187,12 @@ export async function confirmMerge(taskId: string): Promise<void> {
void showAlert("Merge already held behind another merge in this project");
return;
}
if (task.merging_into_parent) {
void showAlert("Already waiting for the parent task's agent to merge this subtask");
return;
}
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
const parent = task.parent_task_id !== undefined ? findTask(task.parent_task_id) : undefined;
let coAuthors;
try {
const [unmerged, people] = await Promise.all([
@@ -1196,11 +1227,36 @@ export async function confirmMerge(taskId: string): Promise<void> {
field.select();
};
const warnings = h("ul", { class: "merge-warnings" });
const question: Node[] = [h("p", { class: "merge-into" }, `Merge into ${target} as:`), row, warnings];
const into = h("p", { class: "merge-into" }, `Merge into ${target} as:`);
const co = coAuthorsSummary(coAuthors);
if (co) {
question.push(co);
const toMain: Node[] = co ? [into, row, co] : [into, row];
let toParent = false;
const question: Node[] = [];
if (parent?.status === "active") {
toParent = true;
const option = (value: boolean, label: string, hint: string) => {
const radio = h("input", { type: "radio", name: "merge-target", checked: value === toParent });
radio.onchange = () => {
toParent = value;
for (const n of toMain) {
(n as HTMLElement).hidden = toParent;
}
};
return h("label", { class: "merge-target" }, radio, h("span", {}, label, h("span", { class: "merge-target-hint" }, hint)));
};
question.push(
h(
"div",
{ class: "merge-targets" },
option(true, `Into ${parent.title}`, "Its agent merges this subtask's branch into the parent's branch; the subtask closes once it has."),
option(false, `Into ${target}`, "Squashed into one commit on the default branch and pushed, like any task."),
),
);
for (const n of toMain) {
(n as HTMLElement).hidden = true;
}
}
question.push(...toMain.slice(0, 2), warnings, ...toMain.slice(2));
const leave = (go: () => void) => {
mergeDrafts.set(taskId, input?.value ?? title);
warnings.closest("dialog")?.close("cancel");
@@ -1210,7 +1266,8 @@ export async function confirmMerge(taskId: string): Promise<void> {
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 } });
const body = toParent ? { target: "parent" as const } : { merge_title: input?.value ?? title };
await post("/api/tasks/{taskId}/merge", { path: { taskId }, body });
});
} finally {
watch.dispose();