Add concurrency caps, the two-pane web UI, AGENTS.md, and plan status
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
import { post, type Attachment, type InboxItem, type InboxResponseCreate } from "./api.js";
|
||||
import { ago, errorText, h, replace } from "./dom.js";
|
||||
import { findTask, onEach, state } from "./state.js";
|
||||
import { reportCard } from "./task.js";
|
||||
|
||||
type Scope = "all" | "project" | "task";
|
||||
type Mode = InboxResponseCreate["type"];
|
||||
|
||||
const drafts = new Map<string, InboxResponseCreate>();
|
||||
const modes = new Map<string, Mode>();
|
||||
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[] => {
|
||||
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
|
||||
if (scope === "task" && task) {
|
||||
return state.inbox.filter((i) => i.task_id === task.id);
|
||||
}
|
||||
if (scope === "project" && task) {
|
||||
return state.inbox.filter((i) => i.project_id === task.project_id);
|
||||
}
|
||||
return state.inbox;
|
||||
};
|
||||
|
||||
const renderTabs = () => {
|
||||
const hasTask = state.selectedTaskId !== undefined;
|
||||
const tab = (s: Scope, label: string) => {
|
||||
const b = h("button", { class: s === scope ? "active" : "", disabled: s !== "all" && !hasTask }, label);
|
||||
b.onclick = () => {
|
||||
scope = s;
|
||||
render();
|
||||
};
|
||||
return b;
|
||||
};
|
||||
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) {
|
||||
alert(errorText(err));
|
||||
}
|
||||
button.disabled = false;
|
||||
render();
|
||||
};
|
||||
replace(submitBar, button);
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
if (scope !== "all" && !state.selectedTaskId) {
|
||||
scope = "all";
|
||||
}
|
||||
renderTabs();
|
||||
const items = visible();
|
||||
const keep = new Set(items.map((i) => i.id));
|
||||
for (const id of cards.keys()) {
|
||||
if (!keep.has(id)) {
|
||||
cards.delete(id);
|
||||
}
|
||||
}
|
||||
for (const id of drafts.keys()) {
|
||||
if (!state.inbox.some((i) => i.id === id)) {
|
||||
drafts.delete(id);
|
||||
modes.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const els = items.map((item) => {
|
||||
const sig = JSON.stringify(item);
|
||||
const cached = cards.get(item.id);
|
||||
if (cached && cached.sig === sig) {
|
||||
return cached.el;
|
||||
}
|
||||
const el = card(item, () => {
|
||||
renderSubmit();
|
||||
});
|
||||
cards.set(item.id, { sig, el });
|
||||
return el;
|
||||
});
|
||||
|
||||
replace(list, ...(els.length > 0 ? els : [h("div", { class: "empty" }, "Nothing waiting on you.")]));
|
||||
renderSubmit();
|
||||
};
|
||||
|
||||
const off = onEach(["inbox", "selection", "tasks"], render);
|
||||
replace(root, h("div", { class: "inbox-view" }, tabs, submitBar, list));
|
||||
render();
|
||||
return off;
|
||||
}
|
||||
|
||||
function card(item: InboxItem, changed: () => void): HTMLElement {
|
||||
const task = findTask(item.task_id);
|
||||
const p = item.payload as Record<string, unknown>;
|
||||
const body = h("div", { class: "card-body" });
|
||||
const actions = h("div", { class: "card-actions" });
|
||||
const el = h(
|
||||
"article",
|
||||
{ class: `card ${item.kind} ${item.priority}` },
|
||||
h(
|
||||
"header",
|
||||
{},
|
||||
h("span", { class: `priority ${item.priority}` }, item.priority),
|
||||
h("span", { class: "kind" }, item.kind.replace("_", " ")),
|
||||
h("a", { href: `#/task/${item.task_id}`, class: "card-task" }, task?.title ?? item.task_id),
|
||||
h("time", { dateTime: item.created_at, title: item.created_at }, ago(item.created_at)),
|
||||
),
|
||||
body,
|
||||
thread(item),
|
||||
actions,
|
||||
);
|
||||
|
||||
const allowed: Mode[] =
|
||||
item.kind === "question" ? ["option", "free_form", "question", "redirect", "snooze"] : ["dismiss", "redirect", "snooze"];
|
||||
const labels: Record<Mode, string> = {
|
||||
option: "Pick",
|
||||
free_form: "Other answer",
|
||||
question: "Ask back",
|
||||
redirect: "Redirect",
|
||||
snooze: "Snooze",
|
||||
dismiss: "Dismiss",
|
||||
};
|
||||
|
||||
const renderActions = () => {
|
||||
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();
|
||||
};
|
||||
return b;
|
||||
}),
|
||||
);
|
||||
replace(actions, tabs, modeInput(item, mode, changed));
|
||||
};
|
||||
|
||||
switch (item.kind) {
|
||||
case "question":
|
||||
replace(
|
||||
body,
|
||||
h("p", { class: "question" }, String(p["question"])),
|
||||
h("p", { class: "why" }, h("strong", {}, "Blocked on: "), String(p["why_blocked"])),
|
||||
attachments((p["attachments"] as Attachment[] | undefined) ?? []),
|
||||
);
|
||||
break;
|
||||
case "report":
|
||||
replace(body, reportCard(p));
|
||||
break;
|
||||
case "session_error":
|
||||
replace(body, h("p", { class: "error-text" }, String(p["error"])));
|
||||
break;
|
||||
case "rate_limited": {
|
||||
const limit = (p["rate_limit"] ?? {}) as Record<string, unknown>;
|
||||
const resets = typeof limit["resetsAt"] === "number" ? new Date(limit["resetsAt"] * 1000).toLocaleString() : "unknown";
|
||||
replace(body, h("p", { class: "error-text" }, String(p["error"])), h("p", {}, `${p["vendor"]} ${limit["rateLimitType"] ?? ""} limit resets ${resets}`));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
renderActions();
|
||||
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 "option": {
|
||||
const p = item.payload as Record<string, unknown>;
|
||||
const options = (p["options"] as string[]) ?? [];
|
||||
const note = h("input", { placeholder: "Optional note", value: draft?.text ?? "" });
|
||||
const choose = (option: string) => set({ item_id: item.id, type: "option", option, text: note.value || undefined });
|
||||
note.oninput = () => {
|
||||
const current = drafts.get(item.id);
|
||||
if (current?.type === "option" && current.option) {
|
||||
choose(current.option);
|
||||
}
|
||||
};
|
||||
return h(
|
||||
"div",
|
||||
{ class: "options" },
|
||||
...options.map((option) => {
|
||||
const radio = h("input", { type: "radio", name: `opt-${item.id}`, checked: drafts.get(item.id)?.option === option });
|
||||
radio.onchange = () => choose(option);
|
||||
return h("label", { class: "option" }, radio, " ", option, option === p["recommended_option"] && h("span", { class: "recommended" }, "recommended"));
|
||||
}),
|
||||
note,
|
||||
);
|
||||
}
|
||||
|
||||
case "free_form":
|
||||
case "question":
|
||||
case "redirect": {
|
||||
const placeholder = {
|
||||
free_form: "Your answer",
|
||||
question: "Your question back to the agent",
|
||||
redirect: "New instructions for the agent",
|
||||
}[mode];
|
||||
const text = h("textarea", { rows: 3, placeholder, value: draft?.type === mode ? (draft.text ?? "") : "" });
|
||||
text.oninput = () => set(text.value.trim() ? { item_id: item.id, type: mode, text: text.value } : undefined);
|
||||
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;
|
||||
}
|
||||
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}` : ""),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function attachments(list: Attachment[]): HTMLElement | null {
|
||||
if (list.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return h(
|
||||
"ul",
|
||||
{ class: "attachments" },
|
||||
...list.map((a) => {
|
||||
const caption = a.caption ? h("div", { class: "caption" }, a.caption) : null;
|
||||
switch (a.kind) {
|
||||
case "file": {
|
||||
const lines = a.start_line ? `:${a.start_line}${a.end_line ? `-${a.end_line}` : ""}` : "";
|
||||
return h("li", {}, h("code", {}, `${a.path ?? ""}${lines}`), caption);
|
||||
}
|
||||
case "diff":
|
||||
return h("li", {}, "diff ", h("code", {}, a.ref ?? ""), caption);
|
||||
case "image":
|
||||
return h("li", {}, a.image?.startsWith("data:image/") ? h("img", { src: a.image, alt: a.caption ?? "" }) : null, caption);
|
||||
case "diagram":
|
||||
return h("li", {}, h("pre", { class: "mermaid-source" }, a.mermaid ?? ""), caption);
|
||||
case "task":
|
||||
return h("li", {}, h("a", { href: `#/task/${a.task_id}` }, "task ", a.task_id ?? ""), caption);
|
||||
case "snapshot":
|
||||
return h("li", {}, "snapshot ", h("code", {}, a.snapshot_id ?? ""), caption);
|
||||
case "url":
|
||||
return h("li", {}, safeLink(a.url ?? ""), caption);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function safeLink(url: string): HTMLElement {
|
||||
const ok = /^https?:\/\//i.test(url);
|
||||
return ok ? h("a", { href: url, target: "_blank", rel: "noopener noreferrer" }, url) : h("code", {}, url);
|
||||
}
|
||||
Reference in New Issue
Block a user