Add concurrency caps, the two-pane web UI, AGENTS.md, and plan status
This commit is contained in:
+271
@@ -0,0 +1,271 @@
|
||||
import { post, type ByreEvent } from "./api.js";
|
||||
import { ago, errorText, h, replace } from "./dom.js";
|
||||
import { currentSession, findTask, onEach, state } from "./state.js";
|
||||
|
||||
const models = ["", "opus", "sonnet", "haiku"];
|
||||
|
||||
export function mountTranscript(root: HTMLElement): () => void {
|
||||
const head = h("header", { class: "task-header" });
|
||||
const transcript = h("div", { class: "transcript" });
|
||||
const composer = h("div", { class: "composer" });
|
||||
let tools = new Map<string, HTMLElement>();
|
||||
|
||||
const renderHead = () => {
|
||||
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
|
||||
if (!task) {
|
||||
replace(head);
|
||||
return;
|
||||
}
|
||||
const sess = currentSession(task.id);
|
||||
const complete = h("button", { class: "danger", title: "Remove the worktree and branch; history stays" }, "Complete task");
|
||||
complete.onclick = async () => {
|
||||
if (!confirm(`Complete "${task.title}"? Its worktree and branch are removed; its history stays.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await post("/api/tasks/{taskId}/complete", { path: { taskId: task.id } });
|
||||
location.hash = "#/";
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
}
|
||||
};
|
||||
replace(
|
||||
head,
|
||||
h("div", { class: "meta" }, h("code", {}, task.branch), h("code", { class: "path" }, task.worktree_path)),
|
||||
h("div", { class: "head-actions" }, sess && h("span", { class: `state ${sess.state}` }, sess.state), complete),
|
||||
);
|
||||
};
|
||||
|
||||
const renderComposer = () => {
|
||||
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
|
||||
if (!task) {
|
||||
replace(composer);
|
||||
return;
|
||||
}
|
||||
const sess = currentSession(task.id);
|
||||
if (!sess || sess.state === "completed") {
|
||||
replace(composer, startForm(task.id));
|
||||
return;
|
||||
}
|
||||
replace(composer, messageForm(sess.id, sess.state));
|
||||
};
|
||||
|
||||
const appendEvents = (events: ByreEvent[]) => {
|
||||
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
|
||||
for (const ev of events) {
|
||||
const el = renderEvent(ev, tools);
|
||||
if (el) {
|
||||
transcript.append(el);
|
||||
}
|
||||
}
|
||||
if (pinned) {
|
||||
transcript.scrollTop = transcript.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const renderAll = () => {
|
||||
tools = new Map();
|
||||
renderHead();
|
||||
renderComposer();
|
||||
if (!state.selectedTaskId) {
|
||||
replace(transcript, h("div", { class: "empty" }, "No task selected. Pick one with ⌘K or from the Tasks tab."));
|
||||
return;
|
||||
}
|
||||
replace(transcript);
|
||||
appendEvents(state.transcript);
|
||||
};
|
||||
|
||||
const offs = [
|
||||
onEach(["selection"], renderAll),
|
||||
onEach(["tasks"], () => {
|
||||
renderHead();
|
||||
renderComposer();
|
||||
}),
|
||||
onEach(["sessions"], (taskId) => {
|
||||
if (taskId === state.selectedTaskId) {
|
||||
renderHead();
|
||||
renderComposer();
|
||||
}
|
||||
}),
|
||||
onEach(["transcript"], (fresh) => appendEvents(fresh as ByreEvent[])),
|
||||
];
|
||||
|
||||
replace(root, h("div", { class: "transcript-view" }, head, transcript, composer));
|
||||
renderAll();
|
||||
return () => {
|
||||
for (const off of offs) {
|
||||
off();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function startForm(taskId: string): HTMLElement {
|
||||
const prompt = h("textarea", { placeholder: "What should the agent do?", rows: 4, required: true });
|
||||
const model = h("select", {}, ...models.map((m) => h("option", { value: m }, m || "default model")));
|
||||
const form = h("form", { class: "compose" }, prompt, h("div", { class: "compose-actions" }, model, h("button", { type: "submit" }, "Start agent")));
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: prompt.value, model: model.value || undefined } });
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
}
|
||||
};
|
||||
submitOnModEnter(prompt, form);
|
||||
return form;
|
||||
}
|
||||
|
||||
function messageForm(sessionId: string, sessionState: string): HTMLElement {
|
||||
const text = h("textarea", { placeholder: sessionState === "idle" ? "Message the agent" : "Wait for the turn to end, or interrupt it", rows: 3 });
|
||||
const send = h("button", { type: "submit", disabled: sessionState !== "idle" }, "Send");
|
||||
const interrupt = h(
|
||||
"button",
|
||||
{ type: "button", class: "danger", disabled: sessionState !== "running" && sessionState !== "queued" },
|
||||
sessionState === "queued" ? "Cancel queued turn" : "Interrupt",
|
||||
);
|
||||
interrupt.onclick = async () => {
|
||||
try {
|
||||
await post("/api/sessions/{sessionId}/interrupt", { path: { sessionId } });
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
}
|
||||
};
|
||||
|
||||
const form = h("form", { class: "compose" }, text, h("div", { class: "compose-actions" }, interrupt, send));
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!text.value.trim()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await post("/api/sessions/{sessionId}/messages", { path: { sessionId }, body: { text: text.value } });
|
||||
text.value = "";
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
}
|
||||
};
|
||||
submitOnModEnter(text, form);
|
||||
return form;
|
||||
}
|
||||
|
||||
function submitOnModEnter(input: HTMLTextAreaElement, form: HTMLFormElement): void {
|
||||
input.onkeydown = (e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderEvent(ev: ByreEvent, tools: Map<string, HTMLElement>): HTMLElement | null {
|
||||
const p = ev.payload as Record<string, unknown>;
|
||||
const when = h("time", { dateTime: ev.created_at, title: ev.created_at }, ago(ev.created_at));
|
||||
|
||||
switch (ev.kind) {
|
||||
case "user.message":
|
||||
return h("div", { class: "msg user" }, h("div", { class: "who" }, ev.actor, " ", when), h("div", { class: "text" }, String(p["text"])));
|
||||
|
||||
case "agent.message":
|
||||
return h("div", { class: "msg agent" }, h("div", { class: "text" }, String(p["text"])));
|
||||
|
||||
case "agent.thinking":
|
||||
return h("details", { class: "thinking" }, h("summary", {}, "thinking"), h("div", { class: "text" }, String(p["text"])));
|
||||
|
||||
case "agent.tool_call": {
|
||||
const result = h("div", { class: "tool-result pending" }, "running…");
|
||||
tools.set(String(p["id"]), result);
|
||||
return h(
|
||||
"details",
|
||||
{ class: "tool" },
|
||||
h("summary", {}, h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"]))),
|
||||
h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2)),
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
case "agent.tool_result": {
|
||||
const target = tools.get(String(p["tool_use_id"]));
|
||||
const body = h("pre", {}, resultText(p["content"]));
|
||||
if (!target) {
|
||||
return h("div", { class: "tool-result orphan" }, body);
|
||||
}
|
||||
target.className = p["is_error"] ? "tool-result error" : "tool-result";
|
||||
replace(target, body);
|
||||
if (p["is_error"]) {
|
||||
target.closest("details")?.classList.add("errored");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case "agent.report":
|
||||
return reportCard(p);
|
||||
|
||||
case "turn.ended":
|
||||
return h("div", { class: `turn-end ${p["is_error"] ? "error" : ""}` }, p["is_error"] ? `turn ended: ${String(p["subtype"]).replaceAll("_", " ")}` : "");
|
||||
|
||||
case "session.created":
|
||||
return h("div", { class: "note" }, `${ev.actor} started a ${p["vendor"]} session`, p["model"] ? ` (${p["model"]})` : "", " ", when);
|
||||
|
||||
case "session.state":
|
||||
return p["error"] ? h("div", { class: "note error" }, `session ${p["state"]}: ${p["error"]}`) : null;
|
||||
|
||||
case "inbox.created": {
|
||||
const kind = p["kind"];
|
||||
if (kind === "question") {
|
||||
const q = p["payload"] as Record<string, unknown>;
|
||||
return h("div", { class: "note ask" }, `asked${p["blocking"] ? " (blocking)" : ""}: `, String(q["question"]));
|
||||
}
|
||||
if (kind === "session_error" || kind === "rate_limited") {
|
||||
const q = p["payload"] as Record<string, unknown>;
|
||||
return h("div", { class: "note error" }, String(q["error"]));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case "task.completed":
|
||||
return h("div", { class: "note" }, `${ev.actor} completed the task `, when);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function reportCard(p: Record<string, unknown>): HTMLElement {
|
||||
const sections: [string, string][] = [
|
||||
["done", "Done"],
|
||||
["deviations", "Deviations from plan"],
|
||||
["open_questions", "Open questions"],
|
||||
["risks", "Risks"],
|
||||
["next_steps", "Next steps"],
|
||||
];
|
||||
return h(
|
||||
"div",
|
||||
{ class: "report" },
|
||||
...sections.map(([key, label]) => {
|
||||
const items = (p[key] as string[] | null | undefined) ?? [];
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return h("div", { class: `report-section ${key}` }, h("h4", {}, label), h("ul", {}, ...items.map((i) => h("li", {}, i))));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function toolBrief(input: unknown): string {
|
||||
if (typeof input !== "object" || input === null) {
|
||||
return "";
|
||||
}
|
||||
const i = input as Record<string, unknown>;
|
||||
const brief = i["command"] ?? i["file_path"] ?? i["pattern"] ?? i["url"] ?? i["question"] ?? i["description"] ?? "";
|
||||
const text = String(brief);
|
||||
return text.length > 120 ? `${text.slice(0, 120)}…` : text;
|
||||
}
|
||||
|
||||
function resultText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((c) => (typeof c === "object" && c !== null && "text" in c ? String(c.text) : JSON.stringify(c))).join("\n");
|
||||
}
|
||||
return JSON.stringify(content, null, 2);
|
||||
}
|
||||
Reference in New Issue
Block a user