Replace browser popups with in-page dialogs; focus new-task prompt
This commit is contained in:
@@ -4,7 +4,6 @@ export type Schemas = components["schemas"];
|
||||
export type User = Schemas["User"];
|
||||
export type Project = Schemas["Project"];
|
||||
export type Task = Schemas["Task"];
|
||||
export type Unmerged = Schemas["Unmerged"];
|
||||
export type AgentSession = Schemas["AgentSession"];
|
||||
export type InboxItem = Schemas["InboxItem"];
|
||||
export type InboxResponse = Schemas["InboxResponse"];
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { get, put, subscribe } from "./api.js";
|
||||
import { errorText, h, replace } from "./dom.js";
|
||||
import { errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { mountNavbar } from "./navbar.js";
|
||||
import { Pane } from "./panes.js";
|
||||
import { handleEvent, loadInbox, loadMe, loadProjects, notify, on, selectTask, state } from "./state.js";
|
||||
@@ -21,7 +21,7 @@ function identityBanner(root: HTMLElement): void {
|
||||
state.me = await put("/api/me", { body: { git_name: name.value, git_email: email.value } });
|
||||
render();
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
void showAlert(errorText(err));
|
||||
}
|
||||
};
|
||||
root.hidden = false;
|
||||
|
||||
@@ -56,3 +56,29 @@ export function ago(iso: string): string {
|
||||
export function errorText(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
function modal(text: string, buttons: { label: string; value: string; class?: string }[]): Promise<string> {
|
||||
const actions = buttons.map((b) => h("button", { type: "submit", value: b.value, class: b.class ?? "" }, b.label));
|
||||
const dialog = h("dialog", { class: "modal" }, h("form", { method: "dialog" }, h("p", { class: "modal-text" }, text), h("div", { class: "modal-actions" }, ...actions)));
|
||||
document.body.append(dialog);
|
||||
return new Promise((resolve) => {
|
||||
dialog.onclose = () => {
|
||||
dialog.remove();
|
||||
resolve(dialog.returnValue);
|
||||
};
|
||||
dialog.showModal();
|
||||
actions.at(-1)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
export async function showAlert(text: string): Promise<void> {
|
||||
await modal(text, [{ label: "OK", value: "ok", class: "primary" }]);
|
||||
}
|
||||
|
||||
export async function showConfirm(text: string, action: string, danger = false): Promise<boolean> {
|
||||
const value = await modal(text, [
|
||||
{ label: "Cancel", value: "cancel" },
|
||||
{ label: action, value: "ok", class: danger ? "danger-primary" : "primary" },
|
||||
]);
|
||||
return value === "ok";
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import { post, type Attachment, type InboxItem, type InboxResponseCreate } from "./api.js";
|
||||
import { mountChanges } from "./changes.js";
|
||||
import { ago, errorText, h, replace } from "./dom.js";
|
||||
import { ago, errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { inlineMarkdown, markdown } from "./markdown.js";
|
||||
import { findTask, onEach, state } from "./state.js";
|
||||
|
||||
@@ -57,7 +57,7 @@ export function mountInbox(root: HTMLElement): () => void {
|
||||
modes.delete(d.item_id);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
void showAlert(errorText(err));
|
||||
}
|
||||
button.disabled = false;
|
||||
render();
|
||||
@@ -180,7 +180,7 @@ function card(item: InboxItem, changed: () => void): HTMLElement {
|
||||
try {
|
||||
await post("/api/inbox/responses", { body: { responses: [{ item_id: item.id, type: "dismiss" }] } });
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
void showAlert(errorText(err));
|
||||
ok.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
import { post } from "./api.js";
|
||||
import { errorText, h, replace } from "./dom.js";
|
||||
import { errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { openTranscript, type View } from "./panes.js";
|
||||
import { activeTasks, currentSession, findTask, onEach, state } from "./state.js";
|
||||
|
||||
@@ -37,7 +37,7 @@ export function mountNavbar(root: HTMLElement, show: (view: View) => void): void
|
||||
render();
|
||||
|
||||
addEventListener("keydown", (e) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey) && !document.querySelector("dialog[open]")) {
|
||||
e.preventDefault();
|
||||
openSwitcher();
|
||||
}
|
||||
@@ -85,7 +85,7 @@ function openSwitcher(): void {
|
||||
const task = await post("/api/projects/{projectId}/tasks", { path: { projectId }, body: { title } });
|
||||
go(task.id);
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
void showAlert(errorText(err));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
import { del, get, post, put, type NotificationPrefs } from "./api.js";
|
||||
import { errorText, h, replace } from "./dom.js";
|
||||
import { errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { on, state } from "./state.js";
|
||||
|
||||
const kinds: { key: keyof NotificationPrefs; label: string }[] = [
|
||||
@@ -29,7 +29,7 @@ export function mountSettings(root: HTMLElement): () => void {
|
||||
try {
|
||||
state.me = await put("/api/me/notifications", { body: { ...me.notification_prefs, [key]: box.checked } });
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
void showAlert(errorText(err));
|
||||
}
|
||||
renderPrefs();
|
||||
};
|
||||
@@ -63,7 +63,7 @@ export function mountSettings(root: HTMLElement): () => void {
|
||||
await enable(status.reg);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
void showAlert(errorText(err));
|
||||
}
|
||||
await renderDevice();
|
||||
};
|
||||
|
||||
+27
-31
@@ -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 "";
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
import { post, type Project } from "./api.js";
|
||||
import { errorText, h, replace } from "./dom.js";
|
||||
import { errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { openTranscript } from "./panes.js";
|
||||
import { currentSession, onEach, state } from "./state.js";
|
||||
|
||||
@@ -51,7 +51,7 @@ function newTaskForm(projectId: string): HTMLElement {
|
||||
const task = await post("/api/projects/{projectId}/tasks", { path: { projectId }, body: { title: title.value } });
|
||||
location.hash = `#/task/${task.id}`;
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
void showAlert(errorText(err));
|
||||
}
|
||||
};
|
||||
return form;
|
||||
@@ -67,7 +67,7 @@ function newProjectForm(): HTMLElement {
|
||||
await post("/api/projects", { body: { name: name.value, repo_path: path.value } });
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
void showAlert(errorText(err));
|
||||
}
|
||||
};
|
||||
return h("details", { class: "add-project", open: state.projects.length === 0 }, h("summary", {}, "Add project"), form);
|
||||
|
||||
Reference in New Issue
Block a user