Inline question choices, compact one-click inbox cards, drop snooze

This commit is contained in:
Ian Gulliver
2026-10-14 23:34:39 -07:00
parent 4dc86e5479
commit c7cfad88a1
30 changed files with 1022 additions and 647 deletions
+7
View File
@@ -45,6 +45,13 @@ async function main() {
replace(document.body, navbar, banner, h("div", { class: "panes" }, left.el, right.el));
mountNavbar(navbar, (view) => right.show(view));
identityBanner(banner);
addEventListener("keydown", (e) => {
if (e.key !== "Escape") {
return;
}
e.preventDefault();
document.querySelector("dialog[open]")?.close();
}, { capture: true });
const status = await get("/api/admin/status");
await Promise.all([loadMe(), loadProjects(), loadInbox()]);
subscribe(status.last_event_seq, handleEvent, (connected) => {
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
+159 -140
View File
@@ -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) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+51 -10
View File
@@ -1,4 +1,8 @@
import { h } from "./dom.js";
const choiceMarker = /^(\([ *]\)|\[[ xX]\])\s+(\S.*?)\s*$/;
const detailsOpen = /^\s*<details>\s*(?:<summary>(.*?)<\/summary>)?\s*$/i;
const summaryLine = /^\s*<summary>(.*?)<\/summary>\s*$/i;
const detailsClose = /^\s*<\/details>\s*$/i;
const fence = /^\s*(```|~~~)/;
const heading = /^(#{1,6})\s+(.*)$/;
const bullet = /^(\s*)[-*+]\s+(.*)$/;
@@ -6,13 +10,13 @@ const ordered = /^(\s*)(\d+)[.)]\s+(.*)$/;
const quote = /^\s*>\s?(.*)$/;
const rule = /^\s*([-*_])(\s*\1){2,}\s*$/;
const tableSep = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
export function markdown(src, className = "md") {
return h("div", { class: className }, ...blocks(src.replace(/\r\n?/g, "\n").split("\n")));
export function markdown(src, className = "md", choice) {
return h("div", { class: className }, ...blocks(src.replace(/\r\n?/g, "\n").split("\n"), choice));
}
export function inlineMarkdown(src) {
return h("span", { class: "md-inline" }, ...inline(src));
}
function blocks(lines) {
function blocks(lines, choice) {
const out = [];
let i = 0;
while (i < lines.length) {
@@ -34,6 +38,34 @@ function blocks(lines) {
out.push(h("pre", { class: "md-code" }, h("code", {}, body.join("\n"))));
continue;
}
if (detailsClose.test(line)) {
i++;
continue;
}
const d = detailsOpen.exec(line);
if (d) {
let title = d[1];
i++;
if (title === undefined && i < lines.length && summaryLine.test(lines[i])) {
title = summaryLine.exec(lines[i])[1];
i++;
}
const body = [];
let depth = 1;
while (i < lines.length) {
const l = lines[i];
i++;
if (detailsOpen.test(l)) {
depth++;
}
else if (detailsClose.test(l) && --depth === 0) {
break;
}
body.push(l);
}
out.push(h("details", { class: "md-details" }, h("summary", {}, ...inline(title?.trim() || "Details")), h("div", { class: "md-details-body" }, ...blocks(body, choice))));
continue;
}
const hd = heading.exec(line);
if (hd) {
const level = Math.min(hd[1].length + 2, 6);
@@ -56,7 +88,7 @@ function blocks(lines) {
continue;
}
if (bullet.test(line) || ordered.test(line)) {
const [list, next] = parseList(lines, i);
const [list, next] = parseList(lines, i, choice);
out.push(list);
i = next;
continue;
@@ -83,6 +115,8 @@ function blocks(lines) {
function startsBlock(lines, i) {
const line = lines[i];
return (fence.test(line) ||
detailsOpen.test(line) ||
detailsClose.test(line) ||
heading.test(line) ||
rule.test(line) ||
quote.test(line) ||
@@ -103,7 +137,7 @@ function joinLines(lines) {
function indentOf(line) {
return line.length - line.trimStart().length;
}
function parseList(lines, start) {
function parseList(lines, start, choice) {
const first = lines[start];
const isOrdered = !bullet.test(first);
const base = indentOf(first);
@@ -141,27 +175,34 @@ function parseList(lines, start) {
body.push(indentOf(next) > base ? next.slice(Math.min(indentOf(next), base + 2)) : next.trim());
i++;
}
list.append(item(body));
list.append(item(body, isOrdered ? undefined : choice));
while (i < lines.length && lines[i].trim() === "" && lines[i + 1] !== undefined && indentOf(lines[i + 1]) === base && (isOrdered ? ordered : bullet).test(lines[i + 1])) {
i++;
}
}
return [list, i];
}
function item(body) {
function item(body, choice) {
const li = h("li");
const m = choice && choiceMarker.exec(body[0]);
const picker = m ? choice(m[2]) : null;
if (picker) {
li.className = "choice";
li.append(picker);
}
const first = picker ? [] : [body[0]];
const rest = body.slice(1);
if (rest.every((l) => !startsBlockLine(l)) && !rest.includes("")) {
li.append(...joinLines(body.map((l) => l.trim())));
li.append(...joinLines([...first, ...rest].map((l) => l.trim())));
return li;
}
const head = [body[0]];
const head = first;
let k = 1;
while (k < body.length && body[k] !== "" && !startsBlockLine(body[k])) {
head.push(body[k].trim());
k++;
}
li.append(...joinLines(head), ...blocks(body.slice(k)));
li.append(...joinLines(head), ...blocks(body.slice(k), choice));
return li;
}
function startsBlockLine(line) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+9 -2
View File
@@ -126,9 +126,16 @@ export function mountTranscript(root) {
const live = sess && sess.state !== "completed" ? sess : undefined;
composerModel = live ? live.model || context.get(live.id)?.model || "" : "";
const form = composeForm(task.id, live?.id, live?.state ?? "idle", composerModel, showPending);
const old = composer.querySelector("textarea");
const caret = old !== null && document.activeElement === old ? [old.selectionStart, old.selectionEnd] : undefined;
replace(composer, form);
if (focusStart && !sess) {
form.querySelector("textarea")?.focus();
const text = form.querySelector("textarea");
if (caret) {
text.focus();
text.setSelectionRange(caret[0], caret[1]);
}
else if (focusStart && !sess) {
text.focus();
}
focusStart = false;
};
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -16
View File
@@ -709,12 +709,10 @@ export interface components {
/** @enum {string} */
priority: "blocked" | "gate" | "review" | "fyi";
blocking: boolean;
/** @description question: context, question, why_blocked, options, recommended_option, attachments. report: text (the final message of a turn that ended normally). session_error: error. rate_limited: vendor, rate_limit (the vendor's rate-limit status), error. */
/** @description question: context (Markdown with the choices inline as `- ( ) text` / `- (*) text` pick-one or `- [ ] text` / `- [x] text` pick-any list items), question, why_blocked, options (each choice's text, in order), multiple (true for pick-any), recommended (the marked choices' text), attachments. report: text (the final message of a turn that ended normally). session_error: error. rate_limited: vendor, rate_limit (the vendor's rate-limit status), error. */
payload: Record<string, never>;
state: components["schemas"]["InboxState"];
/** Format: date-time */
snoozed_until?: string;
/** Format: date-time */
created_at: string;
resolved_by?: string;
/** Format: date-time */
@@ -722,18 +720,16 @@ export interface components {
responses: components["schemas"]["InboxResponse"][];
};
/** @enum {string} */
InboxResponseType: "option" | "other" | "snooze" | "dismiss";
InboxResponseType: "option" | "other" | "dismiss";
InboxResponse: {
id: string;
item_id: string;
user: string;
type: components["schemas"]["InboxResponseType"];
option?: string;
options?: string[];
text?: string;
attachments: components["schemas"]["Attachment"][];
/** Format: date-time */
snooze_until?: string;
/** Format: date-time */
created_at: string;
/** Format: date-time */
delivered_at?: string;
@@ -741,16 +737,11 @@ export interface components {
InboxResponseCreate: {
item_id: string;
type: components["schemas"]["InboxResponseType"];
/** @description option: one of the question's options. */
option?: string;
/** @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. */
text?: string;
attachments?: components["schemas"]["Attachment"][];
/**
* Format: date-time
* @description snooze: when the item reappears.
*/
snooze_until?: string;
};
InboxRespond: {
responses: components["schemas"]["InboxResponseCreate"][];
@@ -1566,7 +1557,6 @@ export interface operations {
project_id?: string;
task_id?: string;
state?: components["schemas"]["InboxState"];
include_snoozed?: boolean;
};
header?: never;
path?: never;
@@ -1574,7 +1564,7 @@ export interface operations {
};
requestBody?: never;
responses: {
/** @description Matching items with their responses. Defaults to open, unsnoozed items. */
/** @description Matching items with their responses. Defaults to open items. */
200: {
headers: {
[name: string]: unknown;
+12
View File
@@ -49,6 +49,18 @@ async function main(): Promise<void> {
mountNavbar(navbar, (view) => right.show(view));
identityBanner(banner);
addEventListener(
"keydown",
(e) => {
if (e.key !== "Escape") {
return;
}
e.preventDefault();
document.querySelector<HTMLDialogElement>("dialog[open]")?.close();
},
{ capture: true },
);
const status = await get("/api/admin/status");
await Promise.all([loadMe(), loadProjects(), loadInbox()]);
+164 -165
View File
@@ -5,16 +5,14 @@ import { inlineMarkdown, markdown } from "./markdown.js";
import { findTask, onEach, state } from "./state.js";
type Scope = "all" | "project" | "task";
type Mode = "pick" | "reply" | "snooze" | "dismiss";
const drafts = new Map<string, InboxResponseCreate>();
const modes = new Map<string, Mode>();
const checked = new Map<string, string[]>();
const texts = new Map<string, string>();
let scope: Scope = "all";
export function mountInbox(root: HTMLElement): () => void {
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<string, { sig: string; el: HTMLElement }>();
const visible = (): InboxItem[] => {
@@ -41,30 +39,6 @@ export function mountInbox(root: HTMLElement): () => void {
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";
@@ -77,10 +51,11 @@ export function mountInbox(root: HTMLElement): () => void {
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);
}
}
}
@@ -90,9 +65,7 @@ export function mountInbox(root: HTMLElement): () => void {
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;
};
@@ -109,12 +82,11 @@ export function mountInbox(root: HTMLElement): () => void {
});
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 () => {
@@ -145,7 +117,22 @@ function groupByTask(items: InboxItem[]): Group[] {
);
}
function card(item: InboxItem, changed: () => void): HTMLElement {
function clickable(el: HTMLElement, go: () => void): HTMLElement {
el.onclick = (e) => {
if (!(e.target as HTMLElement).closest("a")) {
go();
}
};
el.onkeydown = (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
go();
}
};
return el;
}
function card(item: InboxItem): HTMLElement {
const p = item.payload as Record<string, unknown>;
const body = h("div", { class: "card-body" });
const actions = h("div", { class: "card-actions" });
@@ -164,55 +151,155 @@ function card(item: InboxItem, changed: () => void): HTMLElement {
actions,
);
const allowed: Mode[] = item.kind === "question" ? ["pick", "snooze"] : ["dismiss", "reply", "snooze"];
const labels: Record<Mode, string> = {
pick: "Pick",
reply: "Reply",
snooze: "Snooze",
dismiss: "Dismiss",
const busy = () => el.classList.contains("submitting");
const lock = (on: boolean) => {
el.classList.toggle("submitting", on);
for (const c of el.querySelectorAll<HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement>("button, input, textarea")) {
c.disabled = on;
}
};
const submit = async (response: InboxResponseCreate) => {
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"] as string[] | undefined) ?? [];
const recommended = (p["recommended"] as string[] | undefined) ?? [];
const multiple = p["multiple"] === true;
const seen = new Set<string>();
const choice = (text: string) => {
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: HTMLInputElement[] = [];
const textEntry = (label: string, placeholder: string, shared?: { onOpen: () => void; onInput: () => 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");
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 = () => {
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;
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;
}
};
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]);
b.onclick = () => {
modes.set(item.id, m);
drafts.delete(item.id);
if (m === "dismiss") {
drafts.set(item.id, { item_id: item.id, type: "dismiss" });
}
renderActions();
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;
};
return b;
}),
);
replace(actions, tabs, modeInput(item, mode, changed));
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["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"] as Attachment[] | undefined) ?? []),
@@ -236,94 +323,6 @@ function card(item: InboxItem, changed: () => void): HTMLElement {
return el;
}
function modeInput(item: InboxItem, mode: Mode, changed: () => void): HTMLElement | null {
const draft = drafts.get(item.id);
const set = (d: InboxResponseCreate | undefined) => {
if (d) {
drafts.set(item.id, d);
} else {
drafts.delete(item.id);
}
changed();
};
switch (mode) {
case "pick": {
const p = item.payload as Record<string, unknown>;
const options = (p["options"] as string[]) ?? [];
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: [string, () => Date][] = [
["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: InboxItem): HTMLElement | null {
if (item.responses.length === 0) {
return null;
@@ -332,7 +331,7 @@ function thread(item: InboxItem): HTMLElement | null {
"ul",
{ class: "thread" },
...item.responses.map((r) =>
h("li", {}, h("strong", {}, r.user), ` ${r.type.replace("_", " ")}`, r.option ? `: ${r.option}` : "", r.text ? ` — ${r.text}` : ""),
h("li", {}, h("strong", {}, r.user), ` ${r.type.replace("_", " ")}`, r.options ? `: ${r.options.join("; ")}` : "", r.text ? ` — ${r.text}` : ""),
),
);
}
+54 -10
View File
@@ -2,6 +2,12 @@ import { h } from "./dom.js";
type Block = HTMLElement;
export type ChoiceHook = (text: string) => HTMLElement | null;
const choiceMarker = /^(\([ *]\)|\[[ xX]\])\s+(\S.*?)\s*$/;
const detailsOpen = /^\s*<details>\s*(?:<summary>(.*?)<\/summary>)?\s*$/i;
const summaryLine = /^\s*<summary>(.*?)<\/summary>\s*$/i;
const detailsClose = /^\s*<\/details>\s*$/i;
const fence = /^\s*(```|~~~)/;
const heading = /^(#{1,6})\s+(.*)$/;
const bullet = /^(\s*)[-*+]\s+(.*)$/;
@@ -10,15 +16,15 @@ const quote = /^\s*>\s?(.*)$/;
const rule = /^\s*([-*_])(\s*\1){2,}\s*$/;
const tableSep = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
export function markdown(src: string, className = "md"): HTMLElement {
return h("div", { class: className }, ...blocks(src.replace(/\r\n?/g, "\n").split("\n")));
export function markdown(src: string, className = "md", choice?: ChoiceHook): HTMLElement {
return h("div", { class: className }, ...blocks(src.replace(/\r\n?/g, "\n").split("\n"), choice));
}
export function inlineMarkdown(src: string): HTMLElement {
return h("span", { class: "md-inline" }, ...inline(src));
}
function blocks(lines: string[]): Block[] {
function blocks(lines: string[], choice?: ChoiceHook): Block[] {
const out: Block[] = [];
let i = 0;
@@ -44,6 +50,35 @@ function blocks(lines: string[]): Block[] {
continue;
}
if (detailsClose.test(line)) {
i++;
continue;
}
const d = detailsOpen.exec(line);
if (d) {
let title = d[1];
i++;
if (title === undefined && i < lines.length && summaryLine.test(lines[i]!)) {
title = summaryLine.exec(lines[i]!)![1];
i++;
}
const body: string[] = [];
let depth = 1;
while (i < lines.length) {
const l = lines[i]!;
i++;
if (detailsOpen.test(l)) {
depth++;
} else if (detailsClose.test(l) && --depth === 0) {
break;
}
body.push(l);
}
out.push(h("details", { class: "md-details" }, h("summary", {}, ...inline(title?.trim() || "Details")), h("div", { class: "md-details-body" }, ...blocks(body, choice))));
continue;
}
const hd = heading.exec(line);
if (hd) {
const level = Math.min(hd[1]!.length + 2, 6) as 3 | 4 | 5 | 6;
@@ -69,7 +104,7 @@ function blocks(lines: string[]): Block[] {
}
if (bullet.test(line) || ordered.test(line)) {
const [list, next] = parseList(lines, i);
const [list, next] = parseList(lines, i, choice);
out.push(list);
i = next;
continue;
@@ -101,6 +136,8 @@ function startsBlock(lines: string[], i: number): boolean {
const line = lines[i]!;
return (
fence.test(line) ||
detailsOpen.test(line) ||
detailsClose.test(line) ||
heading.test(line) ||
rule.test(line) ||
quote.test(line) ||
@@ -125,7 +162,7 @@ function indentOf(line: string): number {
return line.length - line.trimStart().length;
}
function parseList(lines: string[], start: number): [HTMLElement, number] {
function parseList(lines: string[], start: number, choice?: ChoiceHook): [HTMLElement, number] {
const first = lines[start]!;
const isOrdered = !bullet.test(first);
const base = indentOf(first);
@@ -164,7 +201,7 @@ function parseList(lines: string[], start: number): [HTMLElement, number] {
body.push(indentOf(next) > base ? next.slice(Math.min(indentOf(next), base + 2)) : next.trim());
i++;
}
list.append(item(body));
list.append(item(body, isOrdered ? undefined : choice));
while (i < lines.length && lines[i]!.trim() === "" && lines[i + 1] !== undefined && indentOf(lines[i + 1]!) === base && (isOrdered ? ordered : bullet).test(lines[i + 1]!)) {
i++;
}
@@ -172,20 +209,27 @@ function parseList(lines: string[], start: number): [HTMLElement, number] {
return [list, i];
}
function item(body: string[]): HTMLElement {
function item(body: string[], choice?: ChoiceHook): HTMLElement {
const li = h("li");
const m = choice && choiceMarker.exec(body[0]!);
const picker = m ? choice(m[2]!) : null;
if (picker) {
li.className = "choice";
li.append(picker);
}
const first = picker ? [] : [body[0]!];
const rest = body.slice(1);
if (rest.every((l) => !startsBlockLine(l)) && !rest.includes("")) {
li.append(...joinLines(body.map((l) => l.trim())));
li.append(...joinLines([...first, ...rest].map((l) => l.trim())));
return li;
}
const head: string[] = [body[0]!];
const head: string[] = first;
let k = 1;
while (k < body.length && body[k] !== "" && !startsBlockLine(body[k]!)) {
head.push(body[k]!.trim());
k++;
}
li.append(...joinLines(head), ...blocks(body.slice(k)));
li.append(...joinLines(head), ...blocks(body.slice(k), choice));
return li;
}
+8 -2
View File
@@ -160,9 +160,15 @@ export function mountTranscript(root: HTMLElement): () => void {
const live = sess && sess.state !== "completed" ? sess : undefined;
composerModel = live ? live.model || context.get(live.id)?.model || "" : "";
const form = composeForm(task.id, live?.id, live?.state ?? "idle", composerModel, showPending);
const old = composer.querySelector("textarea");
const caret = old !== null && document.activeElement === old ? [old.selectionStart, old.selectionEnd] : undefined;
replace(composer, form);
if (focusStart && !sess) {
form.querySelector("textarea")?.focus();
const text = form.querySelector("textarea")!;
if (caret) {
text.focus();
text.setSelectionRange(caret[0]!, caret[1]!);
} else if (focusStart && !sess) {
text.focus();
}
focusStart = false;
};
+83 -38
View File
@@ -724,6 +724,21 @@ details.note summary {
cursor: pointer;
}
.md-details {
margin: 6px 0;
}
.md-details summary {
cursor: pointer;
color: var(--accent);
}
.md-details-body {
margin-top: 4px;
padding-left: 10px;
border-left: 1px solid var(--border);
}
.folded-turn {
display: flex;
flex-direction: column;
@@ -753,6 +768,10 @@ details.note .md {
display: none;
}
.compose textarea {
height: calc(4.5lh + 14px);
}
.compose-actions {
display: flex;
justify-content: flex-end;
@@ -965,18 +984,6 @@ pre.diff .meta {
font-size: 12px;
}
.submit-bar {
padding: 8px 16px;
}
.submit-bar:empty {
display: none;
}
.submit-bar button {
width: 100%;
}
.inbox-list {
flex: 1;
overflow-y: auto;
@@ -1097,43 +1104,81 @@ pre.diff .meta {
}
.card-actions {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--border);
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
}
.modes {
display: flex;
flex-wrap: wrap;
gap: 2px;
}
.modes button {
font-size: 11px;
padding: 2px 8px 3px;
}
.options,
.snooze {
display: flex;
flex-direction: column;
gap: 4px;
}
.option,
.snooze label {
.option {
cursor: pointer;
}
.option input,
.snooze input {
.option input {
width: auto;
accent-color: var(--accent);
}
.md li.choice {
list-style: none;
}
.md li.choice + li.choice {
margin-top: 4px;
}
.pick {
display: block;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
}
.pick:hover,
.pick:focus-visible {
border-color: var(--accent);
outline: none;
}
.pick[hidden] {
display: none;
}
.pick.other {
color: var(--muted);
}
.card.submitting .pick {
cursor: progress;
opacity: 0.6;
}
.submit-row {
display: flex;
justify-content: flex-end;
}
.reply-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.reply-row .text-entry {
flex: 1;
}
.text-editor {
display: flex;
flex-direction: column;
gap: 6px;
}
.text-editor[hidden] {
display: none;
}
.recommended {
margin-left: 8px;
font-size: 11px;