Files
byre/web/dist/inbox.js
T

291 lines
13 KiB
JavaScript
Raw Normal View History

import { post } from "./api.js";
2026-10-14 19:35:34 -07:00
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);
2026-10-14 19:35:34 -07:00
const changes = h("section", { class: "inbox-changes" });
replace(root, h("div", { class: "inbox-view" }, changes, tabs, submitBar, list));
const offChanges = mountChanges(changes, true);
render();
2026-10-14 19:35:34 -07:00
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":
2026-10-14 19:35:34 -07:00
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