Files
byre/web/dist/inbox.js
T

298 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 { image, inlineMarkdown, markdown } from "./markdown.js";
2026-10-16 08:42:48 -07:00
import { checkedAnswer, clearAnswer, dropTextAnswer, flushDrafts, hasTextAnswer, setCheckedAnswer, setTextAnswer, textAnswer } from "./drafts.js";
import { imageDrafts, imageTray, releaseImages, uploadImages } from "./images.js";
import { diagram } from "./mermaid.js";
import { focusTab, onEach, state } from "./state.js";
import { taskStyle } from "./taskcolor.js";
2026-10-16 10:57:34 -07:00
const delegateText = "Use your judgment for this decision and continue within the existing task instructions.";
export function mountInbox(root) {
const list = h("div", { class: "inbox-list" });
const cards = new Map();
const nothing = h("div", { class: "empty" }, "Nothing waiting on you in this task.");
const noTask = h("div", { class: "empty" }, "No task selected.");
2026-10-15 22:00:12 -07:00
let shown;
const render = () => {
const taskId = state.selectedTaskId;
const items = state.inbox.filter((i) => i.task_id === taskId).sort((a, b) => a.created_at.localeCompare(b.created_at));
const keep = new Set(items.map((i) => i.id));
for (const id of cards.keys()) {
if (!keep.has(id)) {
cards.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;
};
if (!taskId) {
reconcile(list, [noTask]);
return;
}
2026-10-15 22:00:12 -07:00
const top = items[0]?.id;
reconcile(list, top ? [cardFor(items[0])] : [nothing]);
if (top !== shown) {
shown = top;
list.scrollTop = 0;
}
};
const off = onEach(["inbox", "selection"], render);
2026-10-14 19:35:34 -07:00
const changes = h("section", { class: "inbox-changes" });
replace(root, h("div", { class: "inbox-view" }, changes, list));
2026-10-14 19:35:34 -07:00
const offChanges = mountChanges(changes, true);
render();
2026-10-14 19:35:34 -07:00
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 clickable(el, go) {
el.onclick = (e) => {
2026-10-16 07:04:24 -07:00
if (!e.target.closest("a, button")) {
go();
}
};
el.onkeydown = (e) => {
2026-10-16 07:04:24 -07:00
if (e.target === el && (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" });
2026-10-16 13:56:46 -07:00
const el = h("article", { class: `card ${item.kind}` }, h("header", {}, 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 imageKey = `inbox:${item.id}`;
const submit = async (response, tray) => {
if (busy()) {
return;
}
lock(true);
const imgs = tray?.take() ?? [];
try {
const images = await uploadImages(imgs);
await post("/api/inbox/responses", { body: { responses: [{ ...response, images }] } });
releaseImages(imgs);
2026-10-16 08:42:48 -07:00
clearAnswer(item.id);
const others = state.inbox.some((i) => i.task_id === item.task_id && i.id !== item.id);
if (!others && state.focus.tab === "inbox" && state.selectedTaskId === item.task_id) {
focusTab("transcript");
}
}
catch (err) {
tray?.restore(imgs);
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);
2026-10-16 07:04:24 -07:00
const rec = recommended.includes(text);
const tag = rec && h("span", { class: "recommended" }, "recommended");
const name = h("span", { class: "choice-name" }, inlineMarkdown(text));
if (!multiple) {
2026-10-16 07:04:24 -07:00
const head = h("div", { class: "choice-head" }, name, tag);
return clickable(h("div", { class: rec ? "pick choice-box rec" : "pick choice-box", role: "button", tabIndex: 0 }, head), () => {
void submit({ item_id: item.id, type: "option", options: [text] });
});
}
2026-10-16 08:42:48 -07:00
const input = h("input", { type: "checkbox", checked: checkedAnswer(item.id).includes(text) });
input.onchange = () => {
2026-10-16 08:42:48 -07:00
const now = checkedAnswer(item.id).filter((o) => o !== text);
if (input.checked) {
now.push(text);
}
2026-10-16 08:42:48 -07:00
setCheckedAnswer(item.id, options.filter((o) => now.includes(o)));
renderActions();
};
2026-10-16 07:04:24 -07:00
return h("label", { class: rec ? "option choice-box rec" : "option choice-box" }, h("div", { class: "choice-head" }, input, name, tag));
};
const textEntry = (label, placeholder, shared) => {
const open = hasTextAnswer(item.id) || imageDrafts.has(imageKey);
2026-10-16 08:42:48 -07:00
const text = h("textarea", { rows: 3, placeholder, value: textAnswer(item.id) ?? "" });
const send = h("button", { type: "button", class: "primary" }, "Submit");
const editor = h("div", { class: "text-editor", hidden: !open });
const empty = () => !text.value.trim() && !imageDrafts.has(imageKey);
const tray = imageTray(editor, text, imageKey, () => {
send.disabled = empty();
shared?.onInput();
});
editor.append(tray.el, text, shared ? "" : h("div", { class: "submit-row" }, send));
const block = clickable(h("div", { class: "pick other", role: "button", tabIndex: 0, hidden: open }, label), () => {
2026-10-16 08:42:48 -07:00
setTextAnswer(item.id, "");
block.hidden = true;
editor.hidden = false;
text.focus();
});
const go = () => {
if (shared) {
shared.onSubmit(tray);
return;
}
if (!empty()) {
void submit({ item_id: item.id, type: "other", text: text.value }, tray);
}
};
send.onclick = go;
text.oninput = () => {
2026-10-16 08:42:48 -07:00
setTextAnswer(item.id, text.value);
send.disabled = empty();
shared?.onInput();
};
text.onblur = () => {
if (empty() && !busy()) {
2026-10-16 08:42:48 -07:00
dropTextAnswer(item.id);
editor.hidden = true;
block.hidden = false;
shared?.onInput();
}
2026-10-16 08:42:48 -07:00
flushDrafts();
};
text.onkeydown = (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
go();
}
};
return { el: h("div", { class: "text-entry" }, block, editor), tray };
};
2026-10-16 22:18:00 -07:00
const delegate = () => {
const button = h("button", { type: "button", class: "delegate" }, "Let the agent decide");
button.onclick = () => void submit({ item_id: item.id, type: "other", text: delegateText });
return button;
};
const renderActions = () => {
switch (item.kind) {
case "question": {
const placeholder = "Your answer, a question back, or new instructions";
const send = h("button", { type: "button", class: "primary" }, "Submit");
2026-10-16 08:42:48 -07:00
const typed = () => (textAnswer(item.id) ?? "").trim();
const refresh = () => {
send.disabled = !typed() && !imageDrafts.has(imageKey) && (!multiple || checkedAnswer(item.id).length === 0);
};
const go = (tray) => {
2026-10-16 22:18:00 -07:00
const options = multiple ? checkedAnswer(item.id) : [];
if (options.length > 0) {
void submit({ item_id: item.id, type: "option", options, text: typed() ? textAnswer(item.id) : undefined }, tray);
return;
}
if (typed() || imageDrafts.has(imageKey)) {
void submit({ item_id: item.id, type: "other", text: textAnswer(item.id) ?? "" }, tray);
}
};
const other = textEntry("Other…", placeholder, { onInput: refresh, onSubmit: go });
send.onclick = () => go(other.tray);
refresh();
replace(actions, other.el, h("div", { class: "submit-row" }, delegate(), 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").el, dismiss));
}
}
};
switch (item.kind) {
case "question":
replace(body, markdown(String(p["context"]), "md context", { choice, taskId: item.task_id }), markdown(String(p["question"]), "md question", { taskId: item.task_id }), h("p", { class: "why" }, h("strong", {}, "Blocked on: "), inlineMarkdown(String(p["why_blocked"]), { taskId: item.task_id })), attachments(p["attachments"] ?? [], item.task_id));
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;
}
}
2026-10-16 10:57:34 -07:00
el.addEventListener("keydown", (e) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") {
return;
}
const stops = [...el.querySelectorAll(".pick, .option.choice-box input")].filter((s) => s.offsetParent !== null);
const at = stops.indexOf(e.target);
if (at < 0) {
return;
}
e.preventDefault();
stops[Math.min(Math.max(at + (e.key === "ArrowDown" ? 1 : -1), 0), stops.length - 1)].focus();
});
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}` : "", r.images.length > 0 ? ` (${r.images.length === 1 ? "1 image" : `${r.images.length} images`})` : "")));
}
function attachments(list, taskId) {
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 || a.path ? image(a.image || a.path, a.caption ?? "", taskId) : null, caption);
case "diagram":
return h("li", {}, diagram(a.mermaid ?? ""), caption);
case "task":
return h("li", {}, h("a", { href: `#/task/${a.task_id}`, class: "task-link", style: taskStyle(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