Files
byre/web/dist/task.js
T

421 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { post } from "./api.js";
import { ago, errorText, h, replace } from "./dom.js";
import { inlineMarkdown, markdown } from "./markdown.js";
import { currentSession, findTask, onEach, state } from "./state.js";
const models = ["", "opus", "sonnet", "haiku"];
export function mountTranscript(root) {
const head = h("header", { class: "task-header" });
const transcript = h("div", { class: "transcript" });
const composer = h("div", { class: "composer" });
const visibleReports = new Map();
const acked = new Set();
const ackVisible = () => {
if (document.visibilityState !== "visible") {
return;
}
const ids = [...new Set(visibleReports.values())].filter((id) => !acked.has(id) && state.inbox.some((i) => i.id === id));
if (ids.length === 0) {
return;
}
for (const id of ids) {
acked.add(id);
}
post("/api/inbox/responses", { body: { responses: ids.map((id) => ({ item_id: id, type: "dismiss" })) } }).catch((err) => {
for (const id of ids) {
acked.delete(id);
}
console.error("acknowledge reports", err);
});
};
const observer = new IntersectionObserver((entries) => {
for (const e of entries) {
const id = e.target.dataset["inboxItem"];
if (!id) {
continue;
}
if (e.isIntersecting) {
visibleReports.set(e.target, id);
}
else {
visibleReports.delete(e.target);
}
}
ackVisible();
}, { root: transcript, threshold: 0.5 });
const newCtx = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
return {
tools: new Map(),
run: undefined,
root: task?.worktree_path ?? "",
watchReport: (el, itemId) => {
el.dataset["inboxItem"] = itemId;
observer.observe(el);
},
};
};
let ctx = newCtx();
let context = new Map();
const spinner = h("div", { class: "spinner", hidden: true });
const setRunning = (running) => {
transcript.classList.toggle("running", running);
if (running === !spinner.hidden) {
return;
}
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
spinner.hidden = !running;
if (pinned) {
transcript.scrollTop = transcript.scrollHeight;
}
};
const put = (el) => {
if (spinner.parentNode === transcript) {
transcript.insertBefore(el, spinner);
}
else {
transcript.append(el);
}
};
const renderHead = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
setRunning(task !== undefined && currentSession(task.id)?.state === "running");
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("div", { class: "head-actions" }, sess && contextMeter(context.get(sess.id)), 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(task.id, sess.id, sess.state));
};
const addToRun = (el, call) => {
const run = ctx.run;
if (!run) {
ctx.run = { first: el, group: undefined, calls: call ? [call] : [] };
put(el);
return;
}
if (call) {
run.calls.push(call);
}
if (!run.group) {
const summary = h("summary");
const body = h("div", { class: "tool-group-body" });
const group = h("details", { class: "tool-group" }, summary, body);
run.first.replaceWith(group);
body.append(run.first);
if (run.first.classList.contains("errored")) {
group.classList.add("errored");
}
run.group = { el: group, summary, body };
}
run.group.body.append(el);
replace(run.group.summary, runSummary(run.calls));
};
const appendEvents = (events) => {
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
let contextChanged = false;
for (const ev of events) {
const p = ev.payload;
if (ev.kind === "session.context" && ev.agent_session_id) {
const prev = context.get(ev.agent_session_id);
const window = Number(p["window"]) || prev?.window || 0;
context.set(ev.agent_session_id, { used: Number(p["used"]), window, model: String(p["model"] ?? "") });
contextChanged = true;
continue;
}
if (ev.kind === "agent.tool_call") {
addToRun(renderToolCall(p, ctx), { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) });
continue;
}
const el = renderEvent(ev, ctx);
if (!el) {
continue;
}
if (ev.kind === "agent.thinking") {
addToRun(el, undefined);
continue;
}
ctx.run = undefined;
put(el);
}
if (contextChanged) {
renderHead();
}
if (pinned) {
transcript.scrollTop = transcript.scrollHeight;
}
};
const renderAll = () => {
observer.disconnect();
visibleReports.clear();
ctx = newCtx();
context = 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, spinner);
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)),
onEach(["inbox"], ackVisible),
];
document.addEventListener("visibilitychange", ackVisible);
replace(root, h("div", { class: "transcript-view" }, head, transcript, composer));
renderAll();
return () => {
for (const off of offs) {
off();
}
document.removeEventListener("visibilitychange", ackVisible);
observer.disconnect();
};
}
const composerDrafts = new Map();
function keepDraft(input, key) {
input.value = composerDrafts.get(key) ?? "";
input.addEventListener("input", () => {
if (input.value) {
composerDrafts.set(key, input.value);
}
else {
composerDrafts.delete(key);
}
});
return () => composerDrafts.delete(key);
}
function startForm(taskId) {
const prompt = h("textarea", { placeholder: "What should the agent do?", rows: 4, required: true });
const clearDraft = keepDraft(prompt, `start:${taskId}`);
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 } });
clearDraft();
}
catch (err) {
alert(errorText(err));
}
};
submitOnEnter(prompt, form);
return form;
}
function messageForm(taskId, sessionId, sessionState) {
const text = h("textarea", { placeholder: sessionState === "idle" ? "Message the agent" : "Wait for the turn to end, or interrupt it", rows: 3 });
const clearDraft = keepDraft(text, `message:${taskId}`);
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 = "";
clearDraft();
}
catch (err) {
alert(errorText(err));
}
};
submitOnEnter(text, form);
return form;
}
function submitOnEnter(input, form) {
input.onkeydown = (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
form.requestSubmit();
}
};
}
function contextMeter(c) {
if (!c || c.window <= 0) {
return null;
}
const pct = Math.min(100, (c.used / c.window) * 100);
const level = pct >= 90 ? "danger" : pct >= 70 ? "warn" : "ok";
const title = `context: ${c.used.toLocaleString()} of ${c.window.toLocaleString()} tokens${c.model ? ` (${c.model})` : ""}`;
return h("span", { class: `context-meter ${level}`, title }, `${Math.round(pct)}%`);
}
function renderToolCall(p, ctx) {
const result = h("div", { class: "tool-result pending" }, "running…");
ctx.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"], ctx.root))), h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2)), result);
}
function runSummary(calls) {
const counts = new Map();
for (const c of calls) {
const key = `${c.name}\0${c.target}`;
const entry = counts.get(key);
if (entry) {
entry.n++;
}
else {
counts.set(key, { call: c, n: 1 });
}
}
if (counts.size === 0) {
return h("span", { class: "tool-brief" }, "thinking");
}
const parts = [];
for (const { call, n } of counts.values()) {
if (parts.length > 0) {
parts.push(", ");
}
parts.push(h("span", {}, h("span", { class: "tool-name" }, call.name), call.target ? h("span", { class: "tool-brief" }, " ", call.target) : null, n > 1 ? h("span", { class: "tool-count" }, ` ×${n}`) : null));
}
return h("span", {}, ...parts);
}
function renderEvent(ev, ctx) {
const p = ev.payload;
const when = h("time", { dateTime: ev.created_at, title: ev.created_at }, ago(ev.created_at));
switch (ev.kind) {
case "user.message":
if (ev.actor === "byre") {
return h("div", { class: "note" }, String(p["text"]), " ", when);
}
return h("div", { class: "msg user" }, h("div", { class: "who" }, ev.actor, " ", when), markdown(String(p["text"]), "text md"));
case "agent.message":
return h("div", { class: "msg agent" }, markdown(String(p["text"]), "text md"));
case "agent.thinking":
return h("details", { class: "thinking" }, h("summary", {}, "thinking"), markdown(String(p["text"]), "text md"));
case "agent.tool_result": {
const target = ctx.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.tool")?.classList.add("errored");
target.closest("details.tool-group")?.classList.add("errored");
}
return null;
}
case "agent.report": {
const card = reportCard(p);
if (typeof p["inbox_item_id"] === "string" && p["inbox_item_id"]) {
ctx.watchReport(card, p["inbox_item_id"]);
}
return card;
}
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"];
return h("div", { class: "note ask" }, `asked${p["blocking"] ? " (blocking)" : ""}: `, String(q["question"]));
}
if (kind === "session_error" || kind === "rate_limited") {
const q = p["payload"];
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) {
const sections = [
["done", "Done"],
["deviations", "Deviations from plan"],
["risks", "Risks"],
["next_steps", "Next steps"],
];
return h("div", { class: "report" }, ...sections.map(([key, label]) => {
const items = p[key] ?? [];
if (items.length === 0) {
return null;
}
return h("div", { class: `report-section ${key}` }, h("h4", {}, label), h("ul", {}, ...items.map((i) => h("li", {}, inlineMarkdown(i)))));
}));
}
function toolBrief(input, root) {
if (typeof input !== "object" || input === null) {
return "";
}
const i = input;
const brief = i["command"] ?? i["file_path"] ?? i["pattern"] ?? i["url"] ?? i["question"] ?? i["description"] ?? "";
const text = relative(String(brief), root);
return text.length > 120 ? `${text.slice(0, 120)}…` : text;
}
function toolTarget(input, root) {
if (typeof input !== "object" || input === null) {
return "";
}
const path = input["file_path"];
return typeof path === "string" ? relative(path, root) : "";
}
function relative(path, root) {
return root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
}
function resultText(content) {
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);
}
//# sourceMappingURL=task.js.map