Let task agents create and manage subtasks
This commit is contained in:
Vendored
+19
-4
@@ -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) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -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":
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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))) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+66
-25
@@ -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 {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user