Replace browser popups with in-page dialogs; focus new-task prompt

This commit is contained in:
Ian Gulliver
2026-10-14 22:42:46 -07:00
parent 1dc09c77e0
commit 4ffdc1e517
32 changed files with 270 additions and 195 deletions
+27 -31
View File
@@ -1,5 +1,5 @@
import { get, post, type ByreEvent, type Task, type Unmerged } from "./api.js";
import { ago, errorText, h, replace } from "./dom.js";
import { post, type ByreEvent } from "./api.js";
import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
import { markdown } from "./markdown.js";
import { currentSession, findTask, onEach, state } from "./state.js";
@@ -122,27 +122,22 @@ export function mountTranscript(root: HTMLElement): () => void {
"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} titled "${task.merge_title}", and the task closes. Nothing is pushed.`,
async () => {
await post("/api/tasks/{taskId}/merge", { path: { taskId: task.id } });
},
);
confirmThen(`Merge "${task.title}" into ${target}?`, "Merge", false, 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));
void showAlert(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 () => {
confirmThen(`Discard "${task.title}"?`, "Discard", true, async () => {
await post("/api/tasks/{taskId}/discard", { path: { taskId: task.id } });
location.hash = "#/";
});
@@ -154,6 +149,7 @@ export function mountTranscript(root: HTMLElement): () => void {
};
let composerModel = "";
let focusStart = false;
const renderComposer = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
if (!task) {
@@ -162,9 +158,15 @@ export function mountTranscript(root: HTMLElement): () => void {
}
const sess = currentSession(task.id);
if (!sess || sess.state === "completed") {
replace(composer, startForm(task.id));
const form = startForm(task.id);
replace(composer, form);
if (focusStart && !sess) {
form.querySelector("textarea")?.focus();
}
focusStart = false;
return;
}
focusStart = false;
composerModel = sess.model || context.get(sess.id)?.model || "";
replace(composer, messageForm(task.id, sess.id, sess.state, composerModel));
};
@@ -292,6 +294,7 @@ export function mountTranscript(root: HTMLElement): () => void {
dropStream();
ctx = newCtx();
context = new Map();
focusStart = true;
renderHead();
if (!state.selectedTaskId) {
renderComposer();
@@ -381,11 +384,11 @@ function imageTray(form: HTMLFormElement, input: HTMLTextAreaElement, key: strin
const added: PendingImage[] = [];
for (const f of files) {
if (!imageTypes.includes(f.type)) {
alert(`${f.name || "That file"} isn't a PNG, JPEG, GIF or WebP image.`);
void showAlert(`${f.name || "That file"} isn't a PNG, JPEG, GIF or WebP image.`);
continue;
}
if (f.size > maxImageBytes) {
alert(`${f.name || "That image"} is over ${maxImageBytes >> 20} MiB.`);
void showAlert(`${f.name || "That image"} is over ${maxImageBytes >> 20} MiB.`);
continue;
}
added.push({ file: f, url: URL.createObjectURL(f) });
@@ -482,7 +485,7 @@ function startForm(taskId: string): HTMLElement {
composerDrafts.set(`start:${taskId}`, value);
prompt.value = value;
tray.restore(imgs);
alert(errorText(err));
void showAlert(errorText(err));
}
};
submitOnEnter(prompt, form);
@@ -517,7 +520,7 @@ function messageForm(taskId: string, sessionId: string, sessionState: string, cu
try {
await post("/api/sessions/{sessionId}/interrupt", { path: { sessionId } });
} catch (err) {
alert(errorText(err));
void showAlert(errorText(err));
}
};
@@ -546,7 +549,7 @@ function messageForm(taskId: string, sessionId: string, sessionState: string, cu
composerDrafts.set(`message:${taskId}`, value);
text.value = value;
tray.restore(imgs);
alert(errorText(err));
void showAlert(errorText(err));
}
};
submitOnEnter(text, form);
@@ -573,7 +576,7 @@ function compactButton(sessionId: string, sessionState: string): HTMLElement {
try {
await post("/api/sessions/{sessionId}/compact", { path: { sessionId } });
} catch (err) {
alert(errorText(err));
void showAlert(errorText(err));
b.disabled = false;
}
};
@@ -727,24 +730,17 @@ function messageImages(ids: unknown): HTMLElement | null {
);
}
async function confirmChanges(task: Task, prompt: (u: Unmerged) => string, act: () => Promise<void>) {
async function confirmThen(question: string, action: string, danger: boolean, act: () => Promise<void>) {
if (!(await showConfirm(question, action, danger))) {
return;
}
try {
const u = await get("/api/tasks/{taskId}/unmerged", { path: { taskId: task.id } });
if (!confirm(prompt(u))) {
return;
}
await act();
} catch (err) {
alert(errorText(err));
await showAlert(errorText(err));
}
}
function changesText(u: Unmerged): string {
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
const uncommitted = u.uncommitted > 0 ? `, ${u.uncommitted} uncommitted` : "";
return `${plural(u.commits, "commit")} and ${plural(u.files, "changed file")} (+${u.insertions} −${u.deletions}${uncommitted})`;
}
function toolBrief(input: unknown, root: string): string {
if (typeof input !== "object" || input === null) {
return "";