Inline question choices, compact one-click inbox cards, drop snooze
This commit is contained in:
Vendored
+7
@@ -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) => {
|
||||
|
||||
Vendored
+1
-1
@@ -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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"}
|
||||
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) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+51
-10
@@ -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) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+9
-2
@@ -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;
|
||||
};
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+6
-16
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user