Render Markdown, add ask_human context, simplify inbox responses, group inbox by task

This commit is contained in:
Ian Gulliver
2026-10-14 10:37:04 -07:00
parent e9fd7de556
commit ca2644374d
25 changed files with 950 additions and 283 deletions
+67 -31
View File
@@ -1,5 +1,6 @@
import { post } from "./api.js";
import { ago, errorText, h, replace } from "./dom.js";
import { inlineMarkdown, markdown } from "./markdown.js";
import { findTask, onEach, state } from "./state.js";
import { reportCard } from "./task.js";
const drafts = new Map();
@@ -74,7 +75,7 @@ export function mountInbox(root) {
modes.delete(id);
}
}
const els = items.map((item) => {
const cardFor = (item) => {
const sig = JSON.stringify(item);
const cached = cards.get(item.id);
if (cached && cached.sig === sig) {
@@ -85,6 +86,11 @@ export function mountInbox(root) {
});
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();
@@ -94,22 +100,50 @@ export function mountInbox(root) {
render();
return off;
}
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 task = findTask(item.task_id);
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("a", { href: `#/task/${item.task_id}`, class: "card-task" }, task?.title ?? item.task_id), h("time", { dateTime: item.created_at, title: item.created_at }, ago(item.created_at))), body, thread(item), actions);
const allowed = item.kind === "question" ? ["option", "free_form", "question", "redirect", "snooze"] : ["dismiss", "redirect", "snooze"];
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 = {
option: "Pick",
free_form: "Other answer",
question: "Ask back",
redirect: "Redirect",
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) {
alert(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]);
@@ -128,7 +162,7 @@ function card(item, changed) {
};
switch (item.kind) {
case "question":
replace(body, h("p", { class: "question" }, String(p["question"])), h("p", { class: "why" }, h("strong", {}, "Blocked on: "), String(p["why_blocked"])), attachments(p["attachments"] ?? []));
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, reportCard(p));
@@ -158,33 +192,35 @@ function modeInput(item, mode, changed) {
changed();
};
switch (mode) {
case "option": {
case "pick": {
const p = item.payload;
const options = p["options"] ?? [];
const note = h("input", { placeholder: "Optional note", value: draft?.text ?? "" });
const choose = (option) => set({ item_id: item.id, type: "option", option, text: note.value || undefined });
note.oninput = () => {
const current = drafts.get(item.id);
if (current?.type === "option" && current.option) {
choose(current.option);
}
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: drafts.get(item.id)?.option === option });
radio.onchange = () => choose(option);
return h("label", { class: "option" }, radio, " ", option, option === p["recommended_option"] && h("span", { class: "recommended" }, "recommended"));
}), note);
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 "free_form":
case "question":
case "redirect": {
const placeholder = {
free_form: "Your answer",
question: "Your question back to the agent",
redirect: "New instructions for the agent",
}[mode];
const text = h("textarea", { rows: 3, placeholder, value: draft?.type === mode ? (draft.text ?? "") : "" });
text.oninput = () => set(text.value.trim() ? { item_id: item.id, type: mode, text: text.value } : undefined);
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": {