Files
byre/web/dist/task.js
T
2026-10-14 19:35:34 -07:00

532 lines
22 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 { get, post } from "./api.js";
import { ago, errorText, h, replace } from "./dom.js";
import { markdown } from "./markdown.js";
import { currentSession, findTask, onEach, state } from "./state.js";
const models = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"];
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,
lastMessage: undefined,
turnStart: 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 }, ...[1, 2, 3, 4, 5, 6].map((n) => h("span", { class: `dot${n}` })));
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);
if (task.status !== "active") {
replace(head, h("div", { class: "meta" }, h("code", {}, task.branch)), h("div", { class: "head-actions" }, task.status));
return;
}
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
const merge = h("button", { title: `Squash into one commit on ${target}; nothing is pushed` }, "Merge");
merge.onclick = () => closeTask(task, (u) => `Merge "${task.title}" into ${target}? ${changesText(u)} become one commit on ${target}. Nothing is pushed.`, "/api/tasks/{taskId}/merge");
const discard = h("button", { class: "danger", title: "Delete the worktree and branch without merging" }, "Discard");
discard.onclick = () => closeTask(task, (u) => `Discard "${task.title}"? ${changesText(u)} that aren't on ${target} will be deleted. This can't be undone.`, "/api/tasks/{taskId}/discard");
replace(head, h("div", { class: "meta" }, h("code", {}, task.branch)), h("div", { class: "head-actions" }, sess && contextMeter(context.get(sess.id)), sess && compactButton(sess.id, sess.state), merge, discard));
};
let composerModel = "";
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;
}
composerModel = sess.model || context.get(sess.id)?.model || "";
replace(composer, messageForm(task.id, sess.id, sess.state, composerModel));
};
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));
};
let stream;
const dropStream = () => {
if (!stream) {
return;
}
cancelAnimationFrame(stream.frame);
stream.el.remove();
stream = undefined;
};
const appendDelta = (ev) => {
if (!stream) {
stream = { el: h("div", { class: "msg agent" }), text: "", frame: 0 };
ctx.run = undefined;
put(stream.el);
}
stream.text += String(ev.payload["text"] ?? "");
if (stream.frame) {
return;
}
stream.frame = requestAnimationFrame(() => {
if (!stream) {
return;
}
stream.frame = 0;
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
replace(stream.el, markdown(stream.text, "text md"));
if (pinned) {
transcript.scrollTop = transcript.scrollHeight;
}
});
};
const foldTurn = (start, end) => {
const summary = h("summary");
summary.append(...end.childNodes);
const body = h("div", { class: "folded-turn" });
start.before(h("details", { class: "note" }, summary, body));
let node = start;
while (node && node !== spinner) {
const next = node.nextSibling;
body.append(node);
node = next;
}
};
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 === "agent.message" || ev.kind === "turn.ended" || ev.kind === "user.message") {
dropStream();
}
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;
if (ev.kind === "turn.ended" && p["handoff"] === true && ctx.turnStart?.parentNode === transcript) {
foldTurn(ctx.turnStart, el);
ctx.turnStart = undefined;
continue;
}
put(el);
if (ev.kind === "user.message") {
ctx.turnStart = el;
}
}
if (contextChanged) {
renderHead();
const sess = state.selectedTaskId ? currentSession(state.selectedTaskId) : undefined;
if (sess && !sess.model && (context.get(sess.id)?.model ?? "") !== composerModel) {
renderComposer();
}
}
if (pinned) {
transcript.scrollTop = transcript.scrollHeight;
}
};
const renderAll = () => {
observer.disconnect();
visibleReports.clear();
dropStream();
ctx = newCtx();
context = new Map();
renderHead();
if (!state.selectedTaskId) {
renderComposer();
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);
renderComposer();
};
const offs = [
onEach(["selection"], renderAll),
onEach(["tasks"], () => {
renderHead();
renderComposer();
}),
onEach(["sessions"], (taskId) => {
if (taskId === state.selectedTaskId) {
renderHead();
renderComposer();
}
}),
onEach(["transcript"], (fresh) => appendEvents(fresh)),
onEach(["stream"], (ev) => appendDelta(ev)),
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();
dropStream();
};
}
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 = modelSelect(`start:${taskId}`, models[0]);
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();
const value = prompt.value;
prompt.value = "";
clearDraft();
try {
await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: value, model: model.value } });
}
catch (err) {
composerDrafts.set(`start:${taskId}`, value);
prompt.value = value;
alert(errorText(err));
}
};
submitOnEnter(prompt, form);
return form;
}
const modelChoices = new Map();
function modelSelect(key, current) {
const options = current && !models.includes(current) ? [current, ...models] : models;
const select = h("select", { title: "Model for the next turn" }, ...options.map((m) => h("option", { value: m }, m)));
select.value = modelChoices.get(key) ?? (current || models[0]);
select.onchange = () => {
modelChoices.set(key, select.value);
};
return select;
}
function messageForm(taskId, sessionId, sessionState, currentModel) {
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 model = modelSelect(`message:${sessionId}`, currentModel);
const form = h("form", { class: "compose" }, text, h("div", { class: "compose-actions" }, model, interrupt, send));
form.onsubmit = async (e) => {
e.preventDefault();
const value = text.value;
if (!value.trim()) {
return;
}
text.value = "";
clearDraft();
try {
await post("/api/sessions/{sessionId}/messages", {
path: { sessionId },
body: { text: value, model: model.value !== currentModel ? model.value : undefined },
});
modelChoices.delete(`message:${sessionId}`);
}
catch (err) {
composerDrafts.set(`message:${taskId}`, value);
text.value = value;
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 compactButton(sessionId, sessionState) {
const b = h("button", { type: "button", disabled: sessionState !== "idle", title: "Ask the agent for a handoff, then continue in a new session with a fresh context" }, "Compact");
b.onclick = async () => {
b.disabled = true;
try {
await post("/api/sessions/{sessionId}/compact", { path: { sessionId } });
}
catch (err) {
alert(errorText(err));
b.disabled = false;
}
};
return b;
}
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":
ctx.lastMessage = undefined;
if (ev.actor === "byre") {
const text = String(p["text"]);
const [first, ...rest] = text.split("\n");
if (rest.join("").trim() === "") {
return h("div", { class: "note" }, text, " ", when);
}
return h("details", { class: "note" }, h("summary", {}, first, " ", when), markdown(rest.join("\n"), "text md"));
}
return h("div", { class: "msg user" }, h("div", { class: "who" }, ev.actor, " ", when), markdown(String(p["text"]), "text md"));
case "agent.message":
ctx.lastMessage = h("div", { class: "msg agent" }, markdown(String(p["text"]), "text md"));
return ctx.lastMessage;
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 "turn.ended":
if (typeof p["inbox_item_id"] === "string" && p["inbox_item_id"] && ctx.lastMessage) {
ctx.watchReport(ctx.lastMessage, p["inbox_item_id"]);
}
if (p["handoff"] === true) {
return h("div", { class: "note" }, "handoff written; continuing in a new session with a fresh context ", when);
}
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.model":
return h("div", { class: "note" }, `${ev.actor} switched the model to ${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.merged":
return h("div", { class: "note" }, `${ev.actor} merged the task${p["commit"] ? ` as ${String(p["commit"]).slice(0, 7)}` : ""} `, when);
case "task.discarded":
return h("div", { class: "note" }, `${ev.actor} discarded the task `, when);
}
return null;
}
async function closeTask(task, prompt, path) {
try {
const u = await get("/api/tasks/{taskId}/unmerged", { path: { taskId: task.id } });
if (!confirm(prompt(u))) {
return;
}
await post(path, { path: { taskId: task.id } });
location.hash = "#/";
}
catch (err) {
alert(errorText(err));
}
}
function changesText(u) {
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
const uncommitted = u.uncommitted > 0 ? `, ${u.uncommitted} uncommitted` : "";
return `${plural(u.commits, "commit")} and ${plural(u.files, "changed file")} (+${u.insertions} −${u.deletions}${uncommitted})`;
}
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