276 lines
12 KiB
JavaScript
276 lines
12 KiB
JavaScript
import { post } from "./api.js";
|
|
import { mountChanges } from "./changes.js";
|
|
import { ago, errorText, h, replace, showAlert } from "./dom.js";
|
|
import { image, inlineMarkdown, markdown } from "./markdown.js";
|
|
import { diagram } from "./mermaid.js";
|
|
import { onEach, state } from "./state.js";
|
|
import { taskStyle } from "./taskcolor.js";
|
|
const checked = new Map();
|
|
const texts = new Map();
|
|
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.");
|
|
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);
|
|
}
|
|
}
|
|
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;
|
|
};
|
|
if (!taskId) {
|
|
reconcile(list, [noTask]);
|
|
return;
|
|
}
|
|
reconcile(list, items.length > 0 ? items.map(cardFor) : [nothing]);
|
|
};
|
|
const off = onEach(["inbox", "selection"], render);
|
|
const changes = h("section", { class: "inbox-changes" });
|
|
replace(root, h("div", { class: "inbox-view" }, changes, 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 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, 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 "report":
|
|
replace(body, markdown(String(p["text"] ?? ""), "md summary", { taskId: 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;
|
|
}
|
|
}
|
|
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, 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
|