This commit is contained in:
Ian Gulliver
2026-10-14 21:01:38 -07:00
parent 9e741ede32
commit b2bd894677
20 changed files with 585 additions and 343 deletions
+25 -9
View File
@@ -87,15 +87,32 @@ export function mountTranscript(root) {
}
const sess = currentSession(task.id);
if (task.status !== "active") {
replace(head, h("div", { class: "meta" }, h("code", {}, task.branch)), h("div", { class: "head-actions" }, task.status));
replace(head, h("div", { class: "head-actions" }, task.status));
return;
}
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
const merge = h("button", { title: `Squash into one commit on ${target}; nothing is pushed` }, "Merge");
merge.onclick = () => closeTask(task, (u) => `Merge "${task.title}" into ${target}? ${changesText(u)} become one commit on ${target}. Nothing is pushed.`, "/api/tasks/{taskId}/merge");
const discard = h("button", { class: "danger", title: "Delete the worktree and branch without merging" }, "Discard");
discard.onclick = () => closeTask(task, (u) => `Discard "${task.title}"? ${changesText(u)} that aren't on ${target} will be deleted. This can't be undone.`, "/api/tasks/{taskId}/discard");
replace(head, h("div", { class: "meta" }, h("code", {}, task.branch)), h("div", { class: "head-actions" }, sess && contextMeter(context.get(sess.id)), sess && compactButton(sess.id, sess.state), merge, discard));
const busy = sess?.state === "running" || sess?.state === "queued";
const merge = h("button", { disabled: busy, title: `The agent merges ${target} in, then byre squashes into one commit on ${target}; nothing is pushed` }, "Merge");
merge.onclick = () => confirmChanges(task, (u) => `Merge "${task.title}" into ${target}? The agent first merges ${target} in and resolves any conflicts; when that turn ends, ${changesText(u)} become one commit on ${target} and the task closes. Nothing is pushed.`, async () => {
await post("/api/tasks/{taskId}/merge", { path: { taskId: task.id } });
});
const mergeIn = h("button", { disabled: busy, title: `Ask the agent to merge ${target} into this branch and resolve any conflicts` }, `Merge ${target} in`);
mergeIn.onclick = async () => {
mergeIn.disabled = true;
try {
await post("/api/tasks/{taskId}/merge-in", { path: { taskId: task.id } });
}
catch (err) {
alert(errorText(err));
mergeIn.disabled = false;
}
};
const discard = h("button", { class: "danger", disabled: busy, title: "Delete the worktree and branch without merging" }, "Discard");
discard.onclick = () => confirmChanges(task, (u) => `Discard "${task.title}"? ${changesText(u)} that aren't on ${target} will be deleted. This can't be undone.`, async () => {
await post("/api/tasks/{taskId}/discard", { path: { taskId: task.id } });
location.hash = "#/";
});
replace(head, h("div", { class: "head-actions session-actions" }, sess && contextMeter(context.get(sess.id)), sess && compactButton(sess.id, sess.state), mergeIn), h("div", { class: "head-actions" }, merge, discard));
};
let composerModel = "";
const renderComposer = () => {
@@ -610,14 +627,13 @@ function messageImages(ids) {
return h("a", { href: src, target: "_blank", rel: "noopener" }, h("img", { src, alt: "attached image", loading: "lazy" }));
}));
}
async function closeTask(task, prompt, path) {
async function confirmChanges(task, prompt, act) {
try {
const u = await get("/api/tasks/{taskId}/unmerged", { path: { taskId: task.id } });
if (!confirm(prompt(u))) {
return;
}
await post(path, { path: { taskId: task.id } });
location.hash = "#/";
await act();
}
catch (err) {
alert(errorText(err));
+1 -1
View File
File diff suppressed because one or more lines are too long
+48 -4
View File
@@ -121,7 +121,7 @@ export interface paths {
};
get?: never;
put?: never;
/** @description Squashes the task's worktree, uncommitted changes included, into one commit on the project's default branch, fast-forwards the project's checkout to it, then removes the worktree and branch. Nothing is pushed. Refused while a turn is running or queued, and on merge conflicts. */
/** @description Starts the same agent turn as merge-in. When that turn ends normally, byre squashes the task's worktree into one commit on the project's default branch, fast-forwards the project's checkout to it, then removes the worktree and branch and marks the task merged. Nothing is pushed. Refused while a turn is running or queued. */
post: operations["mergeTask"];
delete?: never;
options?: never;
@@ -129,6 +129,25 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/merge-in": {
parameters: {
query?: never;
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
get?: never;
put?: never;
/** @description Asks the task's agent to merge the project's default branch into the task branch, resolving any conflicts and regenerating generated files. Sent to the task's idle session, or starts a session when it has none. Refused while a turn is running or queued. */
post: operations["mergeIn"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/discard": {
parameters: {
query?: never;
@@ -893,13 +912,38 @@ export interface operations {
};
requestBody?: never;
responses: {
/** @description The merged task. */
200: {
/** @description The merge turn was started or queued. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Task"];
"application/json": components["schemas"]["AgentSession"];
};
};
400: components["responses"]["Error"];
404: components["responses"]["Error"];
409: components["responses"]["Error"];
};
};
mergeIn: {
parameters: {
query?: never;
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The merge turn was started or queued. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AgentSession"];
};
};
400: components["responses"]["Error"];
+34 -10
View File
@@ -111,20 +111,45 @@ export function mountTranscript(root: HTMLElement): () => void {
}
const sess = currentSession(task.id);
if (task.status !== "active") {
replace(head, h("div", { class: "meta" }, h("code", {}, task.branch)), h("div", { class: "head-actions" }, task.status));
replace(head, h("div", { class: "head-actions" }, task.status));
return;
}
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
const merge = h("button", { title: `Squash into one commit on ${target}; nothing is pushed` }, "Merge");
const busy = sess?.state === "running" || sess?.state === "queued";
const merge = h(
"button",
{ disabled: busy, title: `The agent merges ${target} in, then byre squashes into one commit on ${target}; nothing is pushed` },
"Merge",
);
merge.onclick = () =>
closeTask(task, (u) => `Merge "${task.title}" into ${target}? ${changesText(u)} become one commit on ${target}. Nothing is pushed.`, "/api/tasks/{taskId}/merge");
const discard = h("button", { class: "danger", title: "Delete the worktree and branch without merging" }, "Discard");
confirmChanges(
task,
(u) =>
`Merge "${task.title}" into ${target}? The agent first merges ${target} in and resolves any conflicts; when that turn ends, ${changesText(u)} become one commit on ${target} and the task closes. Nothing is pushed.`,
async () => {
await post("/api/tasks/{taskId}/merge", { path: { taskId: task.id } });
},
);
const mergeIn = h("button", { disabled: busy, title: `Ask the agent to merge ${target} into this branch and resolve any conflicts` }, `Merge ${target} in`);
mergeIn.onclick = async () => {
mergeIn.disabled = true;
try {
await post("/api/tasks/{taskId}/merge-in", { path: { taskId: task.id } });
} catch (err) {
alert(errorText(err));
mergeIn.disabled = false;
}
};
const discard = h("button", { class: "danger", disabled: busy, title: "Delete the worktree and branch without merging" }, "Discard");
discard.onclick = () =>
closeTask(task, (u) => `Discard "${task.title}"? ${changesText(u)} that aren't on ${target} will be deleted. This can't be undone.`, "/api/tasks/{taskId}/discard");
confirmChanges(task, (u) => `Discard "${task.title}"? ${changesText(u)} that aren't on ${target} will be deleted. This can't be undone.`, async () => {
await post("/api/tasks/{taskId}/discard", { path: { taskId: task.id } });
location.hash = "#/";
});
replace(
head,
h("div", { class: "meta" }, h("code", {}, task.branch)),
h("div", { class: "head-actions" }, sess && contextMeter(context.get(sess.id)), sess && compactButton(sess.id, sess.state), merge, discard),
h("div", { class: "head-actions session-actions" }, sess && contextMeter(context.get(sess.id)), sess && compactButton(sess.id, sess.state), mergeIn),
h("div", { class: "head-actions" }, merge, discard),
);
};
@@ -706,14 +731,13 @@ function messageImages(ids: unknown): HTMLElement | null {
);
}
async function closeTask(task: Task, prompt: (u: Unmerged) => string, path: "/api/tasks/{taskId}/merge" | "/api/tasks/{taskId}/discard") {
async function confirmChanges(task: Task, prompt: (u: Unmerged) => string, act: () => Promise<void>) {
try {
const u = await get("/api/tasks/{taskId}/unmerged", { path: { taskId: task.id } });
if (!confirm(prompt(u))) {
return;
}
await post(path, { path: { taskId: task.id } });
location.hash = "#/";
await act();
} catch (err) {
alert(errorText(err));
}
+4 -14
View File
@@ -432,20 +432,6 @@ textarea {
display: none;
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
font-size: 11px;
color: var(--muted);
}
.meta code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.head-actions {
display: flex;
gap: 8px;
@@ -453,6 +439,10 @@ textarea {
flex-shrink: 0;
}
.session-actions {
margin-right: auto;
}
.context-meter {
font-size: 11px;
}