Send pick-any checks with Other text; keep inbox focus across updates

This commit is contained in:
Ian Gulliver
2026-10-14 23:54:13 -07:00
parent 2b4d92e7d5
commit 75c76eff0a
11 changed files with 184 additions and 134 deletions
+1 -1
View File
@@ -739,7 +739,7 @@ export interface components {
type: components["schemas"]["InboxResponseType"];
/** @description option: the chosen options; exactly one for a pick-one question, one or more for a pick-any question. */
options?: string[];
/** @description other: the text sent to the agent. */
/** @description other: the text sent to the agent. option on a pick-any question: optional text sent along with the checked options. */
text?: string;
attachments?: components["schemas"]["Attachment"][];
};
+50 -29
View File
@@ -14,6 +14,8 @@ export function mountInbox(root: HTMLElement): () => void {
const tabs = h("nav", { class: "scope-tabs" });
const list = h("div", { class: "inbox-list" });
const cards = new Map<string, { sig: string; el: HTMLElement }>();
const groups = new Map<string, { head: string; h3: HTMLElement; el: HTMLElement }>();
const empty = h("div", { class: "empty" }, "Nothing waiting on you.");
const visible = (): InboxItem[] => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
@@ -70,18 +72,30 @@ export function mountInbox(root: HTMLElement): () => void {
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),
);
const shown = groupByTask(items);
const keepGroups = new Set(shown.map((g) => g.taskId));
for (const id of groups.keys()) {
if (!keepGroups.has(id)) {
groups.delete(id);
}
}
const els = shown.map((g) => {
const projectName = state.projects.find((p) => p.id === g.projectId)?.name ?? g.projectId;
const title = findTask(g.taskId)?.title ?? g.taskId;
const head = JSON.stringify([projectName, title]);
let group = groups.get(g.taskId);
if (!group || group.head !== head) {
const h3 = h("h3", {}, h("span", { class: "project-name" }, projectName, " › "), h("a", { href: `#/task/${g.taskId}` }, title));
group = { head, h3, el: group?.el ?? h("section", {}) };
groups.set(g.taskId, group);
}
group.el.className = `task-group ${g.blocked ? "blocked" : ""}`;
reconcile(group.el, [group.h3, ...g.items.map(cardFor)]);
return group.el;
});
replace(list, ...(els.length > 0 ? els : [h("div", { class: "empty" }, "Nothing waiting on you.")]));
reconcile(list, els.length > 0 ? els : [empty]);
};
const off = onEach(["inbox", "selection", "tasks"], render);
@@ -95,6 +109,18 @@ export function mountInbox(root: HTMLElement): () => void {
};
}
function reconcile(parent: Element, nodes: Node[]): void {
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();
}
}
type Group = { taskId: string; projectId: string; blocked: boolean; items: InboxItem[] };
function groupByTask(items: InboxItem[]): Group[] {
@@ -194,7 +220,6 @@ function card(item: InboxItem): HTMLElement {
const now = (checked.get(item.id) ?? []).filter((o) => o !== text);
if (input.checked) {
now.push(text);
texts.delete(item.id);
}
checked.set(
item.id,
@@ -202,12 +227,10 @@ function card(item: InboxItem): HTMLElement {
);
renderActions();
};
boxes.push(input);
return h("label", { class: "option" }, input, " ", inlineMarkdown(text), tag);
};
const boxes: HTMLInputElement[] = [];
const textEntry = (label: string, placeholder: string, shared?: { onOpen: () => void; onInput: () => void }) => {
const textEntry = (label: string, placeholder: string, shared?: { onInput: () => void; onSubmit: () => void }) => {
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");
@@ -217,9 +240,12 @@ function card(item: InboxItem): HTMLElement {
block.hidden = true;
editor.hidden = false;
text.focus();
shared?.onOpen();
});
const go = () => {
if (shared) {
shared.onSubmit();
return;
}
if (text.value.trim()) {
void submit({ item_id: item.id, type: "other", text: text.value });
}
@@ -266,23 +292,18 @@ function card(item: InboxItem): HTMLElement {
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)! });
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;
}
void submit({ item_id: item.id, type: "option", options: checked.get(item.id) ?? [] });
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;