Files
byre/web/dist/inbox.js
T

291 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 drafts = new Map();
const modes = new Map();
let scope = "all";
export function mountInbox(root) {
const tabs = h("nav", { class: "scope-tabs" });
const list = h("div", { class: "inbox-list" });
const submitBar = h("div", { class: "submit-bar" });
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 renderSubmit = () => {
const ready = [...drafts.values()].filter((d) => state.inbox.some((i) => i.id === d.item_id));
if (ready.length === 0) {
replace(submitBar);
return;
}
const button = h("button", { class: "primary" }, `Submit ${ready.length} ${ready.length === 1 ? "response" : "responses"}`);
button.onclick = async () => {
button.disabled = true;
try {
await post("/api/inbox/responses", { body: { responses: ready } });
for (const d of ready) {
drafts.delete(d.item_id);
modes.delete(d.item_id);
}
}
catch (err) {
void showAlert(errorText(err));
}
button.disabled = false;
render();
};
replace(submitBar, button);
};
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 id of drafts.keys()) {
if (!state.inbox.some((i) => i.id === id)) {
drafts.delete(id);
modes.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, () => {
renderSubmit();
});
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.")]));
renderSubmit();
};
const off = onEach(["inbox", "selection", "tasks"], render);
const changes = h("section", { class: "inbox-changes" });
replace(root, h("div", { class: "inbox-view" }, changes, tabs, submitBar, 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 card(item, changed) {
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 allowed = item.kind === "question" ? ["pick", "snooze"] : ["dismiss", "reply", "snooze"];
const labels = {
pick: "Pick",
reply: "Reply",
snooze: "Snooze",
dismiss: "Dismiss",
};
const renderActions = () => {
if (item.kind === "report") {
const ok = h("button", { type: "button", class: "primary" }, "OK");
ok.onclick = async () => {
ok.disabled = true;
try {
await post("/api/inbox/responses", { body: { responses: [{ item_id: item.id, type: "dismiss" }] } });
}
catch (err) {
void showAlert(errorText(err));
ok.disabled = false;
}
};
replace(actions, h("div", { class: "ack" }, ok));
return;
}
const mode = modes.get(item.id) ?? allowed[0];
const tabs = h("div", { class: "modes" }, ...allowed.map((m) => {
const b = h("button", { type: "button", class: m === mode ? "active" : "" }, labels[m]);
b.onclick = () => {
modes.set(item.id, m);
drafts.delete(item.id);
if (m === "dismiss") {
drafts.set(item.id, { item_id: item.id, type: "dismiss" });
}
renderActions();
changed();
};
return b;
}));
replace(actions, tabs, modeInput(item, mode, changed));
};
switch (item.kind) {
case "question":
replace(body, p["context"] ? markdown(String(p["context"]), "md context") : null, 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 modeInput(item, mode, changed) {
const draft = drafts.get(item.id);
const set = (d) => {
if (d) {
drafts.set(item.id, d);
}
else {
drafts.delete(item.id);
}
changed();
};
switch (mode) {
case "pick": {
const p = item.payload;
const options = p["options"] ?? [];
const text = h("textarea", {
rows: 3,
placeholder: "Your answer, a question back, or new instructions",
value: draft?.type === "other" ? (draft.text ?? "") : "",
hidden: draft?.type !== "other",
});
const setOther = () => set(text.value.trim() ? { item_id: item.id, type: "other", text: text.value } : undefined);
text.oninput = setOther;
const other = h("input", { type: "radio", name: `opt-${item.id}`, checked: draft?.type === "other" });
other.onchange = () => {
text.hidden = false;
text.focus();
setOther();
};
return h("div", { class: "options" }, ...options.map((option) => {
const radio = h("input", { type: "radio", name: `opt-${item.id}`, checked: draft?.type === "option" && draft.option === option });
radio.onchange = () => {
text.hidden = true;
set({ item_id: item.id, type: "option", option });
};
return h("label", { class: "option" }, radio, " ", inlineMarkdown(option), option === p["recommended_option"] && h("span", { class: "recommended" }, "recommended"));
}), h("label", { class: "option" }, other, " Other"), text);
}
case "reply": {
const text = h("textarea", { rows: 3, placeholder: "Reply to the agent", value: draft?.type === "other" ? (draft.text ?? "") : "" });
text.oninput = () => set(text.value.trim() ? { item_id: item.id, type: "other", text: text.value } : undefined);
return text;
}
case "snooze": {
const presets = [
["1 hour", () => new Date(Date.now() + 3600_000)],
["4 hours", () => new Date(Date.now() + 4 * 3600_000)],
[
"Tomorrow 9am",
() => {
const d = new Date();
d.setDate(d.getDate() + 1);
d.setHours(9, 0, 0, 0);
return d;
},
],
];
return h("div", { class: "snooze" }, ...presets.map(([label, when]) => {
const radio = h("input", { type: "radio", name: `snooze-${item.id}` });
radio.onchange = () => set({ item_id: item.id, type: "snooze", snooze_until: when().toISOString() });
return h("label", {}, radio, " ", label);
}));
}
case "dismiss":
return h("p", { class: "hint" }, "Will be dismissed when you submit.");
}
}
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.option ? `: ${r.option}` : "", 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