Files
byre/web/dist/inbox.js
T

310 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { post } from "./api.js";
import { mountChanges } from "./changes.js";
import { ago, errorText, h, replace, showAlert } from "./dom.js";
import { inlineMarkdown, markdown } from "./markdown.js";
import { findTask, onEach, state } from "./state.js";
const checked = new Map();
const texts = new Map();
let scope = "all";
export function mountInbox(root) {
const tabs = h("nav", { class: "scope-tabs" });
const list = h("div", { class: "inbox-list" });
const cards = new Map();
const visible = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
if (scope === "task" && task) {
return state.inbox.filter((i) => i.task_id === task.id);
}
if (scope === "project" && task) {
return state.inbox.filter((i) => i.project_id === task.project_id);
}
return state.inbox;
};
const renderTabs = () => {
const hasTask = state.selectedTaskId !== undefined;
const tab = (s, label) => {
const b = h("button", { class: s === scope ? "active" : "", disabled: s !== "all" && !hasTask }, label);
b.onclick = () => {
scope = s;
render();
};
return b;
};
replace(tabs, tab("all", "All"), tab("project", "This project"), tab("task", "This task"));
};
const render = () => {
if (scope !== "all" && !state.selectedTaskId) {
scope = "all";
}
renderTabs();
const items = visible();
const keep = new Set(items.map((i) => i.id));
for (const id of cards.keys()) {
if (!keep.has(id)) {
cards.delete(id);
}
}
for (const draft of [checked, texts]) {
for (const id of draft.keys()) {
if (!state.inbox.some((i) => i.id === id)) {
draft.delete(id);
}
}
}
const cardFor = (item) => {
const sig = JSON.stringify(item);
const cached = cards.get(item.id);
if (cached && cached.sig === sig) {
return cached.el;
}
const el = card(item);
cards.set(item.id, { sig, el });
return el;
};
const els = groupByTask(items).map((g) => {
const task = findTask(g.taskId);
const project = state.projects.find((p) => p.id === g.projectId);
return h("section", { class: `task-group ${g.blocked ? "blocked" : ""}` }, h("h3", {}, h("span", { class: "project-name" }, project?.name ?? g.projectId, " › "), h("a", { href: `#/task/${g.taskId}` }, task?.title ?? g.taskId)), ...g.items.map(cardFor));
});
replace(list, ...(els.length > 0 ? els : [h("div", { class: "empty" }, "Nothing waiting on you.")]));
};
const off = onEach(["inbox", "selection", "tasks"], render);
const changes = h("section", { class: "inbox-changes" });
replace(root, h("div", { class: "inbox-view" }, changes, tabs, list));
const offChanges = mountChanges(changes, true);
render();
return () => {
off();
offChanges();
};
}
function groupByTask(items) {
const groups = new Map();
for (const item of [...items].sort((a, b) => a.created_at.localeCompare(b.created_at))) {
let g = groups.get(item.task_id);
if (!g) {
g = { taskId: item.task_id, projectId: item.project_id, blocked: false, items: [] };
groups.set(item.task_id, g);
}
g.items.push(item);
g.blocked ||= item.priority === "blocked";
}
const projectName = (id) => state.projects.find((p) => p.id === id)?.name ?? id;
return [...groups.values()].sort((a, b) => Number(b.blocked) - Number(a.blocked) ||
projectName(a.projectId).localeCompare(projectName(b.projectId)) ||
a.items[0].created_at.localeCompare(b.items[0].created_at));
}
function clickable(el, go) {
el.onclick = (e) => {
if (!e.target.closest("a")) {
go();
}
};
el.onkeydown = (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
go();
}
};
return el;
}
function card(item) {
const p = item.payload;
const body = h("div", { class: "card-body" });
const actions = h("div", { class: "card-actions" });
const el = h("article", { class: `card ${item.kind} ${item.priority}` }, h("header", {}, h("span", { class: `priority ${item.priority}` }, item.priority), h("span", { class: "kind" }, item.kind.replace("_", " ")), h("time", { dateTime: item.created_at, title: item.created_at }, ago(item.created_at))), body, thread(item), actions);
const busy = () => el.classList.contains("submitting");
const lock = (on) => {
el.classList.toggle("submitting", on);
for (const c of el.querySelectorAll("button, input, textarea")) {
c.disabled = on;
}
};
const submit = async (response) => {
if (busy()) {
return;
}
lock(true);
try {
await post("/api/inbox/responses", { body: { responses: [response] } });
checked.delete(item.id);
texts.delete(item.id);
}
catch (err) {
void showAlert(errorText(err));
lock(false);
renderActions();
}
};
const options = p["options"] ?? [];
const recommended = p["recommended"] ?? [];
const multiple = p["multiple"] === true;
const seen = new Set();
const choice = (text) => {
if (!options.includes(text) || seen.has(text)) {
return null;
}
seen.add(text);
const tag = recommended.includes(text) && h("span", { class: "recommended" }, "recommended");
if (!multiple) {
return clickable(h("div", { class: "pick", role: "button", tabIndex: 0 }, inlineMarkdown(text), tag), () => {
void submit({ item_id: item.id, type: "option", options: [text] });
});
}
const input = h("input", { type: "checkbox", checked: (checked.get(item.id) ?? []).includes(text) });
input.onchange = () => {
const now = (checked.get(item.id) ?? []).filter((o) => o !== text);
if (input.checked) {
now.push(text);
texts.delete(item.id);
}
checked.set(item.id, options.filter((o) => now.includes(o)));
renderActions();
};
boxes.push(input);
return h("label", { class: "option" }, input, " ", inlineMarkdown(text), tag);
};
const boxes = [];
const textEntry = (label, placeholder, shared) => {
const open = texts.has(item.id);
const text = h("textarea", { rows: 3, placeholder, value: texts.get(item.id) ?? "" });
const send = h("button", { type: "button", class: "primary", disabled: !text.value.trim() }, "Submit");
const editor = h("div", { class: "text-editor", hidden: !open }, text, !shared && h("div", { class: "submit-row" }, send));
const block = clickable(h("div", { class: "pick other", role: "button", tabIndex: 0, hidden: open }, label), () => {
texts.set(item.id, "");
block.hidden = true;
editor.hidden = false;
text.focus();
shared?.onOpen();
});
const go = () => {
if (text.value.trim()) {
void submit({ item_id: item.id, type: "other", text: text.value });
}
};
send.onclick = go;
text.oninput = () => {
texts.set(item.id, text.value);
send.disabled = !text.value.trim();
shared?.onInput();
};
text.onblur = () => {
if (!text.value.trim() && !busy()) {
texts.delete(item.id);
editor.hidden = true;
block.hidden = false;
shared?.onInput();
}
};
text.onkeydown = (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
go();
}
};
return h("div", { class: "text-entry" }, block, editor);
};
const renderActions = () => {
switch (item.kind) {
case "report": {
const ok = h("button", { type: "button", class: "primary" }, "OK");
ok.onclick = () => void submit({ item_id: item.id, type: "dismiss" });
replace(actions, h("div", { class: "ack" }, ok));
return;
}
case "question": {
const placeholder = "Your answer, a question back, or new instructions";
if (!multiple) {
replace(actions, textEntry("Other…", placeholder));
return;
}
const send = h("button", { type: "button", class: "primary" }, "Submit");
const typed = () => (texts.get(item.id) ?? "").trim();
const refresh = () => {
send.disabled = !typed() && (checked.get(item.id) ?? []).length === 0;
};
const other = textEntry("Other…", placeholder, {
onOpen: () => {
checked.delete(item.id);
for (const box of boxes) {
box.checked = false;
}
refresh();
},
onInput: refresh,
});
send.onclick = () => {
if (typed()) {
void submit({ item_id: item.id, type: "other", text: texts.get(item.id) });
return;
}
void submit({ item_id: item.id, type: "option", options: checked.get(item.id) ?? [] });
};
refresh();
replace(actions, other, h("div", { class: "submit-row" }, send));
return;
}
default: {
const dismiss = h("button", { type: "button" }, "Dismiss");
dismiss.onclick = () => void submit({ item_id: item.id, type: "dismiss" });
replace(actions, h("div", { class: "reply-row" }, textEntry("Reply…", "Reply to the agent"), dismiss));
}
}
};
switch (item.kind) {
case "question":
replace(body, markdown(String(p["context"]), "md context", choice), markdown(String(p["question"]), "md question"), h("p", { class: "why" }, h("strong", {}, "Blocked on: "), inlineMarkdown(String(p["why_blocked"]))), attachments(p["attachments"] ?? []));
break;
case "report":
replace(body, markdown(String(p["text"] ?? ""), "md summary"));
break;
case "session_error":
replace(body, h("p", { class: "error-text" }, String(p["error"])));
break;
case "rate_limited": {
const limit = (p["rate_limit"] ?? {});
const resets = typeof limit["resetsAt"] === "number" ? new Date(limit["resetsAt"] * 1000).toLocaleString() : "unknown";
replace(body, h("p", { class: "error-text" }, String(p["error"])), h("p", {}, `${p["vendor"]} ${limit["rateLimitType"] ?? ""} limit resets ${resets}`));
break;
}
}
renderActions();
return el;
}
function thread(item) {
if (item.responses.length === 0) {
return null;
}
return h("ul", { class: "thread" }, ...item.responses.map((r) => h("li", {}, h("strong", {}, r.user), ` ${r.type.replace("_", " ")}`, r.options ? `: ${r.options.join("; ")}` : "", r.text ? ` — ${r.text}` : "")));
}
function attachments(list) {
if (list.length === 0) {
return null;
}
return h("ul", { class: "attachments" }, ...list.map((a) => {
const caption = a.caption ? h("div", { class: "caption" }, a.caption) : null;
switch (a.kind) {
case "file": {
const lines = a.start_line ? `:${a.start_line}${a.end_line ? `-${a.end_line}` : ""}` : "";
return h("li", {}, h("code", {}, `${a.path ?? ""}${lines}`), caption);
}
case "diff":
return h("li", {}, "diff ", h("code", {}, a.ref ?? ""), caption);
case "image":
return h("li", {}, a.image?.startsWith("data:image/") ? h("img", { src: a.image, alt: a.caption ?? "" }) : null, caption);
case "diagram":
return h("li", {}, h("pre", { class: "mermaid-source" }, a.mermaid ?? ""), caption);
case "task":
return h("li", {}, h("a", { href: `#/task/${a.task_id}` }, "task ", a.task_id ?? ""), caption);
case "snapshot":
return h("li", {}, "snapshot ", h("code", {}, a.snapshot_id ?? ""), caption);
case "url":
return h("li", {}, safeLink(a.url ?? ""), caption);
}
}));
}
function safeLink(url) {
const ok = /^https?:\/\//i.test(url);
return ok ? h("a", { href: url, target: "_blank", rel: "noopener noreferrer" }, url) : h("code", {}, url);
}
//# sourceMappingURL=inbox.js.map