Inline question choices, compact one-click inbox cards, drop snooze
This commit is contained in:
Vendored
+159
-140
@@ -3,13 +3,12 @@ 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();
|
||||
const checked = new Map();
|
||||
const texts = 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;
|
||||
@@ -33,30 +32,6 @@ export function mountInbox(root) {
|
||||
};
|
||||
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";
|
||||
@@ -69,10 +44,11 @@ export function mountInbox(root) {
|
||||
cards.delete(id);
|
||||
}
|
||||
}
|
||||
for (const id of drafts.keys()) {
|
||||
if (!state.inbox.some((i) => i.id === id)) {
|
||||
drafts.delete(id);
|
||||
modes.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) => {
|
||||
@@ -81,9 +57,7 @@ export function mountInbox(root) {
|
||||
if (cached && cached.sig === sig) {
|
||||
return cached.el;
|
||||
}
|
||||
const el = card(item, () => {
|
||||
renderSubmit();
|
||||
});
|
||||
const el = card(item);
|
||||
cards.set(item.id, { sig, el });
|
||||
return el;
|
||||
};
|
||||
@@ -93,11 +67,10 @@ export function mountInbox(root) {
|
||||
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));
|
||||
replace(root, h("div", { class: "inbox-view" }, changes, tabs, list));
|
||||
const offChanges = mountChanges(changes, true);
|
||||
render();
|
||||
return () => {
|
||||
@@ -121,53 +94,166 @@ function groupByTask(items) {
|
||||
projectName(a.projectId).localeCompare(projectName(b.projectId)) ||
|
||||
a.items[0].created_at.localeCompare(b.items[0].created_at));
|
||||
}
|
||||
function card(item, changed) {
|
||||
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 allowed = item.kind === "question" ? ["pick", "snooze"] : ["dismiss", "reply", "snooze"];
|
||||
const labels = {
|
||||
pick: "Pick",
|
||||
reply: "Reply",
|
||||
snooze: "Snooze",
|
||||
dismiss: "Dismiss",
|
||||
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 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));
|
||||
const submit = async (response) => {
|
||||
if (busy()) {
|
||||
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" });
|
||||
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);
|
||||
texts.delete(item.id);
|
||||
}
|
||||
checked.set(item.id, options.filter((o) => now.includes(o)));
|
||||
renderActions();
|
||||
};
|
||||
boxes.push(input);
|
||||
return h("label", { class: "option" }, input, " ", inlineMarkdown(text), tag);
|
||||
};
|
||||
const boxes = [];
|
||||
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();
|
||||
shared?.onOpen();
|
||||
});
|
||||
const go = () => {
|
||||
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;
|
||||
}
|
||||
renderActions();
|
||||
changed();
|
||||
};
|
||||
return b;
|
||||
}));
|
||||
replace(actions, tabs, modeInput(item, mode, changed));
|
||||
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 other = textEntry("Other…", placeholder, {
|
||||
onOpen: () => {
|
||||
checked.delete(item.id);
|
||||
for (const box of boxes) {
|
||||
box.checked = false;
|
||||
}
|
||||
refresh();
|
||||
},
|
||||
onInput: refresh,
|
||||
});
|
||||
send.onclick = () => {
|
||||
if (typed()) {
|
||||
void submit({ item_id: item.id, type: "other", text: texts.get(item.id) });
|
||||
return;
|
||||
}
|
||||
void submit({ item_id: item.id, type: "option", options: checked.get(item.id) ?? [] });
|
||||
};
|
||||
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, 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"] ?? []));
|
||||
replace(body, markdown(String(p["context"]), "md context", choice), 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"));
|
||||
@@ -185,78 +271,11 @@ function card(item, changed) {
|
||||
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}` : "")));
|
||||
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) {
|
||||
if (list.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user