Files
byre/web/dist/inbox.js
T

334 lines
14 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 groups = new Map();
const empty = h("div", { class: "empty" }, "Nothing waiting on you.");
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 shown = groupByTask(items);
const keepGroups = new Set(shown.map((g) => g.taskId));
for (const id of groups.keys()) {
if (!keepGroups.has(id)) {
groups.delete(id);
}
}
const els = shown.map((g) => {
const projectName = state.projects.find((p) => p.id === g.projectId)?.name ?? g.projectId;
const title = findTask(g.taskId)?.title ?? g.taskId;
const head = JSON.stringify([projectName, title]);
let group = groups.get(g.taskId);
if (!group || group.head !== head) {
const h3 = h("h3", {}, h("span", { class: "project-name" }, projectName, " › "), h("a", { href: `#/task/${g.taskId}` }, title));
group = { head, h3, el: group?.el ?? h("section", {}) };
groups.set(g.taskId, group);
}
group.el.className = `task-group ${g.blocked ? "blocked" : ""}`;
reconcile(group.el, [group.h3, ...g.items.map(cardFor)]);
return group.el;
});
reconcile(list, els.length > 0 ? els : [empty]);
};
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 reconcile(parent, nodes) {
nodes.forEach((node, i) => {
const at = parent.childNodes[i];
if (at !== node) {
parent.insertBefore(node, at ?? null);
}
});
while (parent.childNodes.length > nodes.length) {
parent.lastChild.remove();
}
}
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);
}
checked.set(item.id, options.filter((o) => now.includes(o)));
renderActions();
};
return h("label", { class: "option" }, input, " ", inlineMarkdown(text), tag);
};
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();
});
const go = () => {
if (shared) {
shared.onSubmit();
return;
}
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 go = () => {
const options = checked.get(item.id) ?? [];
if (options.length > 0) {
void submit({ item_id: item.id, type: "option", options, text: typed() ? texts.get(item.id) : undefined });
return;
}
if (typed()) {
void submit({ item_id: item.id, type: "other", text: texts.get(item.id) });
}
};
const other = textEntry("Other…", placeholder, { onInput: refresh, onSubmit: go });
send.onclick = go;
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