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
+19 -4
View File
@@ -2,7 +2,7 @@ import { get, post } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { markdown } from "./markdown.js";
import { models } from "./task.js";
import { currentSession, focusedProject, focusTask, on, onEach, state, taskState } from "./state.js";
import { currentSession, findTask, focusedProject, focusTask, on, onEach, state, taskState } from "./state.js";
import { taskStyle } from "./taskcolor.js";
const refreshKinds = new Set(["task.merged", "project.created"]);
export function mountProject(root) {
@@ -86,20 +86,35 @@ function projectBlock(project, todo, starting, start) {
const active = (state.tasks.get(project.id) ?? []).filter((t) => t.status === "active");
return h("section", { class: "project" }, h("h2", {}, project.name, " ", h("span", { class: "repo" }, project.default_branch)), h("h3", {}, "Tasks"), tasksBlock(active), h("h3", {}, "TODO"), ...todoBlock(project, todo, active, starting, start));
}
function nested(active) {
const ids = new Set(active.map((t) => t.id));
const out = [];
const add = (task, depth) => {
out.push({ task, depth });
for (const child of active.filter((t) => t.parent_task_id === task.id).reverse()) {
add(child, depth + 1);
}
};
for (const task of active.filter((t) => t.parent_task_id === undefined || !ids.has(t.parent_task_id))) {
add(task, 0);
}
return out;
}
function tasksBlock(active) {
if (active.length === 0) {
return h("p", { class: "empty-row" }, "No active tasks.");
}
return h("ul", { class: "task-list" }, ...active.map((task) => {
return h("ul", { class: "task-list" }, ...nested(active).map(({ task, depth }) => {
const ts = taskState(task);
const count = state.inbox.filter((item) => item.task_id === task.id).length;
return h("li", { class: task.id === state.selectedTaskId ? "selected" : "", style: taskStyle(task.id) }, h("a", {
const classes = [task.id === state.selectedTaskId && "selected", task.parent_task_id !== undefined && "subtask"].filter(Boolean).join(" ");
return h("li", { class: classes, style: `${taskStyle(task.id)}; --depth: ${depth}` }, h("a", {
href: `#/task/${task.id}`,
onclick: (e) => {
e.preventDefault();
focusTask(task.id);
},
}, h("span", { class: `dot ${ts ?? "none"}`, title: ts ?? "no session" }), h("span", { class: "task-title task-name" }, task.title), count > 0 && h("span", { class: "badge", title: "inbox items" }, count), task.merge_queued_at !== undefined && h("span", { class: "task-state warn", title: "Waiting for the merge in progress in this project to finish" }, "merge held"), h("span", { class: "task-state" }, ts ?? "no agent")));
}, task.parent_task_id !== undefined && h("span", { class: "subtask-mark", title: `Subtask of ${findTask(task.parent_task_id)?.title ?? task.parent_task_id}` }, "↳"), h("span", { class: `dot ${ts ?? "none"}`, title: ts ?? "no session" }), h("span", { class: "task-title task-name" }, task.title), count > 0 && h("span", { class: "badge", title: "inbox items" }, count), task.merge_queued_at !== undefined && h("span", { class: "task-state warn", title: "Waiting for the merge in progress in this project to finish" }, "merge held"), task.merging_into_parent && h("span", { class: "task-state warn", title: "Waiting for the parent task's agent to merge it" }, "merging into parent"), h("span", { class: "task-state" }, ts ?? "no agent")));
}));
}
function todoBlock(project, todo, active, starting, start) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -252,6 +252,8 @@ export function handleEvent(ev) {
case "task.merge_title":
case "task.merge_queued":
case "task.merge_unqueued":
case "task.parent_merge_started":
case "task.parent_merge_stopped":
case "task.merge_started":
case "task.merge_ended":
case "task.discard_started":
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,7 +1,7 @@
import { post } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { focusLead } from "./panes.js";
import { activeTasks, focusedProject, focusProject, focusTab, focusTask, lastVisits, onEach, state, taskState } from "./state.js";
import { activeTasks, findTask, focusedProject, focusProject, focusTab, focusTask, lastVisits, onEach, state, taskState } from "./state.js";
import { confirmMerge, models } from "./task.js";
import { taskStyle } from "./taskcolor.js";
export function bindShortcuts() {
@@ -132,7 +132,7 @@ export function openSwitcher() {
.sort((a, b) => Number(b.count > 0) - Number(a.count > 0) || a.visited - b.visited);
rows = matches.map(({ project, task, count }) => {
const ts = taskState(task);
const el = h("li", { style: taskStyle(task.id) }, h("span", { class: `dot ${ts ?? "none"}`, title: ts ?? "no session" }), h("span", { class: "task-title" }, h("span", { class: "project-name" }, project.name), " ", h("span", { class: "task-name" }, task.title)), count > 0 && h("span", { class: "badge", title: "inbox items" }, count), task.merge_queued_at !== undefined && h("span", { class: "task-state warn", title: "Waiting for the merge in progress in this project to finish" }, "merge held"), h("span", { class: "task-state" }, ts ?? "no agent"));
const el = h("li", { style: taskStyle(task.id) }, h("span", { class: `dot ${ts ?? "none"}`, title: ts ?? "no session" }), h("span", { class: "task-title" }, h("span", { class: "project-name" }, project.name), " ", task.parent_task_id !== undefined && h("span", { class: "subtask-mark" }, `${findTask(task.parent_task_id)?.title ?? task.parent_task_id} ↳ `), h("span", { class: "task-name" }, task.title)), count > 0 && h("span", { class: "badge", title: "inbox items" }, count), task.merge_queued_at !== undefined && h("span", { class: "task-state warn", title: "Waiting for the merge in progress in this project to finish" }, "merge held"), task.merging_into_parent && h("span", { class: "task-state warn", title: "Waiting for the parent task's agent to merge it" }, "merging into parent"), h("span", { class: "task-state" }, ts ?? "no agent"));
return { el, pick: () => go(task.id) };
});
for (const p of (targets ?? state.projects).filter((p) => `${p.name} project`.toLowerCase().includes(q))) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+66 -25
View File
@@ -93,13 +93,18 @@ function taskHead(rerender) {
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)`,
}, held ? "Merge held" : "Merge");
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)`,
}, 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}`);
mergeIn.onclick = async () => {
@@ -909,24 +914,18 @@ function renderEvent(ev, ctx) {
return 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) => {
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);
case "review.finished": {
@@ -943,6 +942,9 @@ function renderEvent(ev, ctx) {
return h("div", { class: "note" }, `${ev.actor} exposed "${String(p["name"])}" at `, h("a", { href: url, target: "_blank", rel: "noopener" }, url), " ", when);
}
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" }, `${ev.actor} merged the task${p["commit"] ? ` as ${String(p["commit"]).slice(0, 7)}` : ""}${p["pushed"] ? ` and pushed it to ${String(p["pushed"])}` : ""} `, when);
case "task.summary":
return h("div", { class: "note" }, `${ev.actor} set the task summary `, when);
@@ -953,6 +955,21 @@ function renderEvent(ev, ctx) {
}
return null;
}
function taskLink(id) {
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) => {
e.preventDefault();
focusTask(task.id);
},
}, task.title);
}
function messageImages(ids) {
if (!Array.isArray(ids) || ids.length === 0) {
return null;
@@ -976,7 +993,12 @@ export async function confirmMerge(taskId) {
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([
@@ -1012,11 +1034,29 @@ export async function confirmMerge(taskId) {
field.select();
};
const warnings = h("ul", { class: "merge-warnings" });
const question = [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 = co ? [into, row, co] : [into, row];
let toParent = false;
const question = [];
if (parent?.status === "active") {
toParent = true;
const option = (value, label, hint) => {
const radio = h("input", { type: "radio", name: "merge-target", checked: value === toParent });
radio.onchange = () => {
toParent = value;
for (const n of toMain) {
n.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.hidden = true;
}
}
question.push(...toMain.slice(0, 2), warnings, ...toMain.slice(2));
const leave = (go) => {
mergeDrafts.set(taskId, input?.value ?? title);
warnings.closest("dialog")?.close("cancel");
@@ -1026,7 +1066,8 @@ export async function confirmMerge(taskId) {
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" } : { merge_title: input?.value ?? title };
await post("/api/tasks/{taskId}/merge", { path: { taskId }, body });
});
}
finally {
+1 -1
View File
File diff suppressed because one or more lines are too long