Files
byre/web/dist/task.js
T
2026-10-16 11:26:15 -07:00

1305 lines
52 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, copyable, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
import { markdown } from "./markdown.js";
import { previewURL } from "./preview.js";
import { findingCount, reviewCounts, severityCounts, unignoredFindings } from "./reviews.js";
import { composerText, endSend, flushDrafts, forgetLostImages, lostImageCount, setComposerImages, setComposerText, startSend } from "./drafts.js";
import { currentSession, findTask, focusTab, focusTask, markSummaryRead, on, onEach, pickReview, pinSnapshot, setFocus, showReview, state, transcriptLoading } from "./state.js";
import { taskStyle } from "./taskcolor.js";
import { fillTodoLinks } from "./todolink.js";
export const models = [
"claude-opus-5-5",
"claude-sonnet-5-5",
"claude-haiku-5-5",
"claude-fable-5-1",
"gpt-6.1-sol",
"gpt-6-astra",
"gpt-6-sol",
"gpt-6-luna",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
];
const quietReply = "NO_REPLY";
const taskSource = {
key: () => state.selectedTaskId,
events: () => state.transcript,
loading: transcriptLoading,
session: () => (state.selectedTaskId ? currentSession(state.selectedTaskId) : undefined),
root: () => (state.selectedTaskId ? (findTask(state.selectedTaskId)?.worktree_path ?? "") : ""),
taskId: () => state.selectedTaskId,
resetTopics: ["selection"],
refreshTopics: ["tasks", "sessions"],
transcriptTopic: "transcript",
streamTopic: "stream",
readyTopic: "ready",
empty: "No task selected. Ask your lead, or pick one with ⌘K.",
canSend: () => true,
placeholder: "Message the agent. Paste or drop images to attach them.",
send: async ({ key, sessionId, text, images, model, firstModel }) => {
if (sessionId) {
await post("/api/sessions/{sessionId}/messages", { path: { sessionId }, body: { text, images, model } });
return;
}
await post("/api/tasks/{taskId}/sessions", { path: { taskId: key }, body: { prompt: text, model: firstModel, images } });
},
};
export function mountTranscript(root) {
return mountChat(root, taskSource, taskHead);
}
function taskHead(rerender) {
let unmerged;
let timer;
const load = async () => {
const taskId = state.selectedTaskId;
if (!taskId) {
return;
}
try {
const u = await get("/api/tasks/{taskId}/unmerged", { path: { taskId } });
if (state.selectedTaskId !== taskId) {
return;
}
const has = u.commits > 0 || u.files > 0;
if (unmerged?.taskId === taskId && unmerged.has === has) {
return;
}
unmerged = { taskId, has };
rerender();
}
catch (err) {
console.error("load unmerged changes", err);
}
};
return {
reset: () => {
clearTimeout(timer);
void load();
},
dispose: () => clearTimeout(timer),
onEvents: (events) => {
if (events.some((ev) => ev.kind === "agent.tool_result" || ev.kind === "turn.ended")) {
clearTimeout(timer);
timer = setTimeout(() => void load(), 1500);
}
},
render: (usage) => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
if (!task) {
return [];
}
const sess = currentSession(task.id);
if (task.status !== "active") {
return [h("div", { class: "head-actions" }, task.status)];
}
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
const busy = sess?.state === "running";
const held = task.merge_queued_at !== undefined;
const intoParent = task.merging_into_parent;
const merge = h("button", {
class: "merge-button",
disabled: busy || held || intoParent || unmerged?.taskId !== task.id || !unmerged.has,
title: intoParent
? "Waiting for the parent task's agent to merge this subtask"
: held
? "Waiting for the merge in progress in this project to finish"
: task.parent_task_id !== undefined
? `Merge this subtask into its parent task's branch, or into ${target} (⌘S)`
: `The agent merges ${target} in, then byre squashes into one commit on ${target} and pushes it to origin; held while another task in the project is merging (⌘S)`,
}, intoParent ? "Merging into parent" : held ? "Merge held" : "Merge");
merge.onclick = () => confirmMerge(task.id);
const mergeIn = h("button", { disabled: busy, title: `Ask the agent to bring ${target} into this branch and resolve any conflicts` }, `Sync from ${target}`);
mergeIn.onclick = async () => {
mergeIn.disabled = true;
try {
await post("/api/tasks/{taskId}/merge-in", { path: { taskId: task.id } });
}
catch (err) {
void showAlert(errorText(err));
mergeIn.disabled = false;
}
};
const discard = h("button", { class: "danger", disabled: busy, title: "The agent writes the task summary, then byre deletes the worktree and branch without merging" }, "Discard");
discard.onclick = () => confirmThen(`Discard "${task.title}"?`, "Discard", true, discard, async () => {
await post("/api/tasks/{taskId}/discard", { path: { taskId: task.id } });
await setFocus({ task_id: null });
});
return [
h("div", { class: "head-actions session-actions" }, compactButton(sess?.id, sess?.state, sess && usage(sess.id)), mergeIn),
h("div", { class: "head-actions" }, merge, discard),
];
},
};
}
export function mountChat(root, src, makeHead) {
const head = h("header", { class: "task-header" });
const transcript = h("div", { class: "transcript" });
const composer = h("div", { class: "composer" });
let stuck = true;
const pin = () => {
if (stuck) {
transcript.scrollTop = transcript.scrollHeight;
}
};
transcript.addEventListener("scroll", () => {
stuck = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
});
transcript.addEventListener("load", pin, true);
const resizes = new ResizeObserver(pin);
resizes.observe(transcript);
const mutations = new MutationObserver(pin);
mutations.observe(transcript, { childList: true, subtree: true, characterData: true });
const shown = new Set();
const markShown = () => {
const taskId = ctx.taskId;
if (!taskId || document.visibilityState !== "visible") {
return;
}
for (const el of shown) {
markSummaryRead(taskId, Number(el.dataset["seq"]));
}
};
const sightings = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
shown.add(e.target);
}
else {
shown.delete(e.target);
}
}
markShown();
}, { root: transcript });
document.addEventListener("visibilitychange", markShown);
const jump = () => {
const target = state.jump;
if (!target || target.taskId !== ctx.taskId) {
return;
}
const end = transcript.querySelector(`.turn-end[data-seq="${target.seq}"]`);
if (!end) {
return;
}
state.jump = undefined;
const msg = end.previousElementSibling;
const box = transcript.getBoundingClientRect();
if (msg?.classList.contains("agent")) {
transcript.scrollTop += msg.getBoundingClientRect().top - box.top - 8;
}
else {
transcript.scrollTop += end.getBoundingClientRect().bottom - box.bottom + 8;
}
stuck = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
};
const newCtx = () => ({
tools: new Map(),
subagents: new Map(),
reviewers: new Set(),
reviewButtons: new Map(),
run: undefined,
held: undefined,
open: false,
drawn: undefined,
quiet: false,
handoff: undefined,
root: src.root,
taskId: src.taskId(),
});
let ctx = newCtx();
let context = new Map();
const headImpl = makeHead(() => renderHead());
const scanner = h("div", { class: "scanner", hidden: true });
const loading = h("div", { class: "empty" }, "loading…");
const setRunning = (running) => {
transcript.classList.toggle("running", running);
scanner.hidden = !running;
};
const putBefore = (el, anchor) => {
if (anchor?.parentNode === transcript) {
transcript.insertBefore(el, anchor);
}
else {
transcript.insertBefore(el, scanner);
}
};
const syncRunning = () => setRunning(src.session()?.state === "running" && ctx.open);
const release = () => {
if (!ctx.held) {
return;
}
ctx.held = undefined;
ctx.open = true;
syncRunning();
};
const put = (el, keepHeld = false) => {
if (ctx.handoff) {
ctx.handoff.body.append(el);
return;
}
if (!keepHeld) {
release();
}
ctx.drawn?.push(el);
putBefore(el, pending[0]?.el);
};
const fold = () => {
for (const el of ctx.drawn ?? []) {
el.remove();
}
ctx.drawn = undefined;
ctx.held = undefined;
ctx.open = false;
ctx.quiet = false;
syncRunning();
};
const renderHead = () => {
syncRunning();
if (!src.key()) {
replace(head);
return;
}
replace(head, ...headImpl.render((id) => context.get(id)));
};
let composerModel = "";
let focusStart = false;
const renderComposer = () => {
const key = src.key();
const old = composer.querySelector("textarea");
if (old) {
releaseInput(old);
}
if (!key) {
replace(composer);
return;
}
const sess = src.session();
const live = sess && sess.state !== "completed" ? sess : undefined;
composerModel = live ? live.model || context.get(live.id)?.model || "" : "";
const form = composeForm(src, key, live?.id, live?.state ?? "idle", composerModel, live?.vendor, showPending);
const caret = old !== null && document.activeElement === old ? [old.selectionStart, old.selectionEnd] : undefined;
replace(composer, form);
const text = form.querySelector("textarea");
if (caret) {
text.focus();
text.setSelectionRange(caret[0], caret[1]);
}
else if (focusStart && !sess) {
text.focus();
}
focusStart = false;
};
let pending = [];
const showPending = (text, imgs) => {
const el = h("div", { class: "msg user pending" }, h("div", { class: "who" }, src.session()?.state === "running" ? "queued…" : "sending…"), imgs.length > 0 ? h("div", { class: "msg-images" }, ...imgs.map((img) => h("img", { src: img.url, alt: "attached image" }))) : null, text.trim() ? markdown(text, "text md") : null);
const entry = { el, text, imgs };
pending.push(entry);
ctx.run = undefined;
putBefore(el, undefined);
stuck = true;
pin();
return () => {
el.remove();
pending = pending.filter((p) => p !== entry);
};
};
const confirmPending = (text) => {
const entry = pending.find((p) => p.text === text);
if (!entry) {
return;
}
entry.el.remove();
releaseImages(entry.imgs);
pending = pending.filter((p) => p !== entry);
};
const runHead = (run) => [subagentTag(run.parent, ctx), runSummary(run.calls)];
const addToRun = (el, call, parent) => {
const sub = ctx.subagents.get(parent);
if (sub) {
sub.run = extendRun(sub.run, el, call, parent, (e) => sub.body.append(e));
return;
}
ctx.run = extendRun(ctx.run, el, call, parent, put);
};
const extendRun = (run, el, call, parent, place) => {
if (!run || run.parent !== parent) {
place(el);
return { first: el, group: undefined, calls: call ? [call] : [], parent };
}
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);
ctx.drawn?.push(group);
body.append(run.first);
run.group = { el: group, summary, body };
}
run.group.body.append(el);
replace(run.group.summary, ...runHead(run));
return run;
};
let stream;
const dropStream = () => {
if (!stream) {
return;
}
clearTimeout(stream.timer);
stream.el.remove();
stream = undefined;
};
const appendDelta = (ev) => {
if (ctx.handoff || (ev.agent_session_id && ctx.reviewers.has(ev.agent_session_id))) {
return;
}
if (!stream) {
stream = { el: h("div", { class: "msg agent", hidden: true }), text: "", timer: 0 };
ctx.run = undefined;
put(stream.el, true);
}
stream.text += String(ev.payload["text"] ?? "");
if (stream.timer) {
return;
}
stream.timer = setTimeout(() => {
if (!stream) {
return;
}
stream.timer = 0;
stream.el.hidden = ctx.held !== undefined || quietReply.startsWith(stream.text.trim());
replace(stream.el, markdown(stream.text, "text md", { taskId: ctx.taskId }));
fillTodoLinks(stream.el, models[0]);
pin();
}, 100);
};
const openHandoff = () => {
const summary = h("summary", {}, "writing a handoff…");
const body = h("div", { class: "folded-turn" });
put(h("details", { class: "note" }, summary, body));
ctx.handoff = { summary, body };
};
const closeHandoff = (end) => {
const handoff = ctx.handoff;
if (!handoff) {
return undefined;
}
ctx.handoff = undefined;
if (end) {
replace(handoff.summary, ...end.childNodes);
}
else {
replace(handoff.summary, "handoff not written");
}
return handoff.summary;
};
const appendEvents = (events) => {
let contextChanged = false;
headImpl.onEvents?.(events);
for (const ev of events) {
const p = ev.payload;
const parent = parentOf(p);
if (ev.kind === "session.created" && p["role"] === "reviewer" && ev.agent_session_id) {
ctx.reviewers.add(ev.agent_session_id);
continue;
}
if (ev.agent_session_id && ctx.reviewers.has(ev.agent_session_id)) {
continue;
}
if (ev.kind === "agent.vendor") {
claudeTaskStatus(p, ctx);
continue;
}
if ((ev.kind === "agent.message" && !parent) || ev.kind === "turn.ended" || ev.kind === "user.message") {
dropStream();
}
if (ev.kind === "user.message" && ev.actor !== "byre") {
confirmPending(String(p["text"] ?? ""));
}
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 === "snapshot.created") {
ctx.run = undefined;
put(snapRow(p, ctx));
continue;
}
if (ev.kind === "review.started" || ev.kind === "review.finished" || ev.kind === "review.finding") {
const r = p;
const button = ctx.reviewButtons.get(r.snapshot_n);
if (button) {
markReview(button, r);
}
}
const sub = ctx.subagents.get(parent);
if (ev.kind === "agent.tool_call" && isSubagentCall(p)) {
const el = renderSubagent(p, ctx);
if (sub) {
sub.run = undefined;
sub.body.append(el);
continue;
}
ctx.run = undefined;
put(el);
continue;
}
if (ev.kind === "agent.tool_call") {
addToRun(renderToolCall(p, ctx), { name: toolLabel(String(p["name"])), target: toolTarget(p["input"], ctx.root()) }, parent);
continue;
}
const el = renderEvent(ev, ctx);
if (!el) {
continue;
}
if (ev.kind === "agent.thinking") {
addToRun(el, undefined, parent);
continue;
}
if (sub) {
sub.run = undefined;
sub.body.append(el);
continue;
}
ctx.run = undefined;
if (ev.kind === "turn.ended") {
ctx.open = false;
}
if (ev.kind === "turn.ended" && p["handoff"] === true && ctx.handoff) {
closeHandoff(el);
continue;
}
if (ev.kind === "turn.ended" || (ev.kind === "user.message" && p["mid_turn"] !== true)) {
closeHandoff(undefined);
}
if (ev.kind === "user.message" && p["handoff"] === true) {
openHandoff();
}
if (ev.kind === "turn.ended" && ctx.quiet && ctx.drawn) {
fold();
continue;
}
if (ev.kind === "turn.ended" || ev.kind === "user.message") {
ctx.drawn = undefined;
}
if (ev.kind === "user.message" && ev.actor === "byre" && ev.owner && p["mid_turn"] !== true && p["handoff"] !== true) {
ctx.held = el;
ctx.open = false;
ctx.drawn = [];
continue;
}
if (ev.kind === "user.message" && p["mid_turn"] !== true) {
ctx.open = true;
}
if (ev.kind === "turn.ended" && p["summary"] === true && ctx.taskId) {
el.dataset["seq"] = String(ev.seq);
sightings.observe(el);
}
put(el);
}
syncRunning();
if (contextChanged) {
renderHead();
const sess = src.session();
if (sess && !sess.model && (context.get(sess.id)?.model ?? "") !== composerModel) {
renderComposer();
}
}
fillTodoLinks(transcript, models[0]);
pin();
jump();
};
const renderAll = () => {
stuck = true;
dropStream();
pending = [];
sightings.disconnect();
shown.clear();
ctx = newCtx();
context = new Map();
focusStart = true;
headImpl.reset?.();
renderHead();
if (!src.key()) {
renderComposer();
replace(transcript, h("div", { class: "empty" }, src.empty), scanner);
return;
}
replace(transcript, scanner);
if (src.loading()) {
transcript.prepend(loading);
}
appendEvents(src.events());
renderComposer();
};
const offs = [
onEach(src.resetTopics, renderAll),
onEach(src.refreshTopics, () => {
renderHead();
renderComposer();
}),
onEach([src.transcriptTopic], (fresh) => appendEvents(fresh)),
onEach([src.streamTopic], (ev) => appendDelta(ev)),
onEach([src.readyTopic], () => loading.remove()),
onEach(["tasks", "sessions"], () => fillTodoLinks(transcript, models[0])),
on("jump", jump),
];
replace(root, h("div", { class: "transcript-view" }, head, transcript, composer));
renderAll();
return () => {
for (const off of offs) {
off();
}
headImpl.dispose?.();
resizes.disconnect();
mutations.disconnect();
sightings.disconnect();
document.removeEventListener("visibilitychange", markShown);
dropStream();
const input = composer.querySelector("textarea");
if (input) {
releaseInput(input);
}
flushDrafts();
};
}
const composerInputs = new Map();
function releaseInput(input) {
for (const [key, el] of composerInputs) {
if (el === input) {
composerInputs.delete(key);
}
}
}
function keepDraft(input, key) {
composerInputs.set(key, input);
input.value = composerText(key);
input.addEventListener("input", () => setComposerText(key, input.value));
input.addEventListener("blur", flushDrafts);
}
function lostImagesNote(key) {
const n = lostImageCount(key);
if (n === 0) {
return null;
}
const dismiss = h("button", { type: "button", class: "remove", title: "Dismiss" }, "×");
const note = h("div", { class: "compose-note" }, `${n === 1 ? "1 image" : `${n} images`} from before the reload need re-attaching.`, dismiss);
dismiss.onclick = () => {
forgetLostImages(key);
note.remove();
};
return note;
}
const imageDrafts = new Map();
const imageTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
const maxImageBytes = 5 << 20;
function imageTray(form, input, key) {
const el = h("div", { class: "compose-images" });
const render = () => {
const imgs = imageDrafts.get(key) ?? [];
setComposerImages(key, imgs.length);
el.hidden = imgs.length === 0;
replace(el, ...imgs.map((img) => {
const remove = h("button", { type: "button", class: "remove", title: "Remove image" }, "×");
remove.onclick = () => {
URL.revokeObjectURL(img.url);
const rest = (imageDrafts.get(key) ?? []).filter((i) => i !== img);
if (rest.length > 0) {
imageDrafts.set(key, rest);
}
else {
imageDrafts.delete(key);
}
render();
};
return h("div", { class: "thumb" }, h("img", { src: img.url, alt: "attached image" }), remove);
}));
};
const add = (files) => {
const added = [];
for (const f of files) {
if (!imageTypes.includes(f.type)) {
void showAlert(`${f.name || "That file"} isn't a PNG, JPEG, GIF or WebP image.`);
continue;
}
if (f.size > maxImageBytes) {
void showAlert(`${f.name || "That image"} is over ${maxImageBytes >> 20} MiB.`);
continue;
}
added.push({ file: f, url: URL.createObjectURL(f) });
}
if (added.length === 0) {
return false;
}
imageDrafts.set(key, [...(imageDrafts.get(key) ?? []), ...added]);
render();
return true;
};
input.addEventListener("paste", (e) => {
const files = [...(e.clipboardData?.files ?? [])].filter((f) => f.type.startsWith("image/"));
if (files.length > 0) {
e.preventDefault();
add(files);
}
});
form.addEventListener("dragover", (e) => {
if (e.dataTransfer?.types.includes("Files")) {
e.preventDefault();
form.classList.add("dropping");
}
});
form.addEventListener("dragleave", (e) => {
if (!form.contains(e.relatedTarget)) {
form.classList.remove("dropping");
}
});
form.addEventListener("drop", (e) => {
form.classList.remove("dropping");
const files = [...(e.dataTransfer?.files ?? [])];
if (files.length > 0) {
e.preventDefault();
add(files);
}
});
render();
return {
el,
take: () => {
const imgs = imageDrafts.get(key) ?? [];
imageDrafts.delete(key);
render();
return imgs;
},
restore: (imgs) => {
if (imgs.length > 0) {
imageDrafts.set(key, [...imgs, ...(imageDrafts.get(key) ?? [])]);
}
render();
},
};
}
async function uploadImages(imgs) {
if (imgs.length === 0) {
return undefined;
}
const ids = [];
for (const img of imgs) {
const blob = await post("/api/blobs", { body: img.file });
ids.push(blob.id);
}
return ids;
}
function releaseImages(imgs) {
for (const img of imgs) {
URL.revokeObjectURL(img.url);
}
}
const modelChoices = new Map();
function vendorOf(model) {
return model.startsWith("gpt-") ? "codex" : "claude";
}
function modelSelect(key, current, vendor) {
const offered = vendor ? models.filter((m) => vendorOf(m) === vendor) : models;
const options = current && !offered.includes(current) ? [current, ...offered] : offered;
const select = h("select", { title: "Model for the next turn" }, ...options.map((m) => h("option", { value: m }, m)));
select.value = modelChoices.get(key) ?? (current || options[0]);
select.onchange = () => {
modelChoices.set(key, select.value);
};
return select;
}
function composeForm(src, key, sessionId, sessionState, currentModel, vendor, showPending) {
const text = h("textarea", { placeholder: src.placeholder, rows: 3 });
const draftKey = `message:${key}`;
keepDraft(text, draftKey);
const send = h("button", { type: "submit", disabled: !src.canSend(sessionState) }, "Send");
const interrupt = h("button", { type: "button", class: "danger", disabled: sessionState !== "running" }, "Interrupt");
interrupt.onclick = async () => {
if (!sessionId) {
return;
}
try {
await post("/api/sessions/{sessionId}/interrupt", { path: { sessionId } });
}
catch (err) {
void showAlert(errorText(err));
}
};
const modelKey = `message:${sessionId ?? key}`;
const model = modelSelect(modelKey, currentModel, vendor);
const form = h("form", { class: "compose" });
const tray = imageTray(form, text, draftKey);
form.append(lostImagesNote(draftKey) ?? "", tray.el, text, h("div", { class: "compose-actions" }, model, interrupt, send));
form.onsubmit = async (e) => {
e.preventDefault();
const value = text.value;
if (!value.trim() && !imageDrafts.has(draftKey)) {
return;
}
const imgs = tray.take();
text.value = "";
const sent = startSend(draftKey, value, imgs.length);
const cancel = showPending(value, imgs);
try {
const images = await uploadImages(imgs);
await src.send({ key, sessionId, text: value, images, model: model.value !== currentModel ? model.value : undefined, firstModel: model.value });
modelChoices.delete(modelKey);
endSend(sent, true);
form.querySelector(".compose-note")?.remove();
}
catch (err) {
cancel();
endSend(sent, false);
const live = composerInputs.get(draftKey) ?? text;
live.value = composerText(draftKey);
tray.restore(imgs);
void showAlert(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();
}
};
}
export function compactButton(sessionId, sessionState, c) {
const pct = c && c.window > 0 ? Math.min(100, (c.used / c.window) * 100) : 0;
const level = pct >= 90 ? "danger" : pct >= 70 ? "warn" : "ok";
const usage = c && c.window > 0 ? `\ncontext: ${c.used.toLocaleString()} of ${c.window.toLocaleString()} tokens${c.model ? ` (${c.model})` : ""}` : "";
const b = h("button", {
type: "button",
class: `compact ${level}`,
style: `--fill: ${pct}%`,
disabled: !sessionId || sessionState !== "idle",
title: `Ask the agent for a handoff, then continue in a new session with a fresh context${usage}`,
}, "Compact");
b.onclick = async () => {
if (!sessionId) {
return;
}
b.disabled = true;
try {
await post("/api/sessions/{sessionId}/compact", { path: { sessionId } });
}
catch (err) {
void showAlert(errorText(err));
b.disabled = false;
}
};
return b;
}
function parentOf(p) {
return typeof p["parent_tool_use_id"] === "string" ? p["parent_tool_use_id"] : "";
}
function subagentTag(parent, ctx) {
if (!parent) {
return null;
}
if (ctx.subagents.has(parent)) {
return null;
}
return h("span", { class: "subagent-tag" }, "↳ subagent");
}
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", {}, subagentTag(parentOf(p), ctx), h("span", { class: "tool-name" }, toolLabel(String(p["name"]))), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"], ctx.root()))), copyable(h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2))), result);
}
function toolLabel(name) {
const prefix = "mcp__byre__";
if (!name.startsWith(prefix)) {
return name;
}
return name
.slice(prefix.length)
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
function isSubagentCall(p) {
return p["name"] === "Agent" || p["name"] === "Task";
}
function renderSubagent(p, ctx) {
const input = (typeof p["input"] === "object" && p["input"] !== null ? p["input"] : {});
const id = String(p["id"]);
const type = String(input["subagent_type"] ?? "subagent");
const description = String(input["description"] ?? "");
const status = h("span", { class: "subagent-status" }, "starting…");
const result = h("div", { class: "tool-result pending" }, "running…");
const body = h("div", { class: "subagent-body" });
const tag = subagentTag(parentOf(p), ctx);
ctx.tools.set(id, result);
ctx.subagents.set(id, { type, description, status, body, run: undefined });
return h("details", { class: "tool subagent" }, h("summary", {}, tag, h("span", { class: "tool-name" }, `${p["name"]} ${type}`), " ", h("span", { class: "tool-brief" }, description), " ", status), markdown(String(input["prompt"] ?? ""), "text md subagent-prompt", { taskId: ctx.taskId }), body, result);
}
function claudeTaskStatus(p, ctx) {
if (p["type"] !== "system") {
return;
}
const sub = ctx.subagents.get(String(p["tool_use_id"]));
if (!sub) {
return;
}
switch (p["subtype"]) {
case "task_started":
replace(sub.status, p["is_backgrounded"] === true ? "running in the background" : "running");
return;
case "task_progress":
replace(sub.status, String(p["description"] ?? "running"));
return;
case "task_notification":
sub.status.className = `subagent-status ${p["status"] === "completed" ? "done" : "error"}`;
replace(sub.status, String(p["status"]));
return;
}
}
function markReview(button, r) {
const n = findingCount(r);
button.classList.toggle("attention", n > 0);
if (r.state === "running") {
replace(button, "review", h("span", { class: "review-mark spinner", title: "review in progress" }));
return;
}
if (r.state === "failed") {
replace(button, "review");
return;
}
if (r.state === "skipped") {
replace(button, "review", h("span", { class: "review-mark skipped", title: "skipped: no changes" }, "●"));
return;
}
if (n === 0) {
replace(button, "review", h("span", { class: "review-mark clean", title: Object.keys(r.finding_states).length > 0 ? "every finding addressed" : "no findings" }, "✓"));
return;
}
replace(button, "review", h("span", { class: "badge", title: `${n} open findings` }, n));
}
function snapRow(snap, ctx) {
const taskId = ctx.taskId;
const changes = h("button", { type: "button", title: `Show snapshot ${snap.n} in the Changes tab` }, "changes");
changes.onclick = () => {
pinSnapshot(snap.n);
focusTab("changes");
};
const fork = h("button", { type: "button", title: "Start a new task from this snapshot, its agent continuing the conversation as it stood here" }, "fork");
fork.onclick = async () => {
if (!taskId) {
return;
}
fork.disabled = true;
try {
const task = await post("/api/tasks/{taskId}/snapshots/{n}/fork", { path: { taskId, n: snap.n } });
focusTask(task.id);
}
catch (err) {
fork.disabled = false;
void showAlert(errorText(err));
}
};
const review = h("button", { type: "button", title: `Show the review of snapshot ${snap.n} in the Reviews tab` }, "review");
review.onclick = () => showReview(snap.n);
ctx.reviewButtons.set(snap.n, review);
const time = new Date(snap.created_at).toLocaleTimeString([], { hour12: false });
return h("div", { class: "snap-row", title: `${snap.trigger.replace("_", " ")} · ${time}` }, `snapshot #${snap.n}`, changes, fork, review);
}
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 md = { taskId: ev.task_id };
const when = h("time", { dateTime: ev.created_at, title: ev.created_at }, ago(ev.created_at));
switch (ev.kind) {
case "user.message":
ctx.quiet = false;
if (ev.actor === "byre") {
const text = String(p["text"]);
const first = text.split("\n")[0] ?? "";
const brief = first.length > 120 ? `${first.slice(0, 120)}…` : first;
return h("details", { class: "tool byre-prompt", hidden: p["continuation"] === true }, h("summary", {}, h("span", { class: "tool-name" }, "byre"), " ", h("span", { class: "tool-brief" }, brief), " ", when), markdown(text, "text md", md));
}
return h("div", { class: "msg user" }, h("div", { class: "who" }, ev.actor, " ", when), messageImages(p["images"]), String(p["text"] ?? "").trim() ? markdown(String(p["text"]), "text md", md) : null);
case "agent.message":
if (parentOf(p)) {
return h("div", { class: "msg agent subagent" }, h("div", { class: "who" }, subagentTag(parentOf(p), ctx), " ", when), markdown(String(p["text"]), "text md", md));
}
if (String(p["text"]).trim().split("\n").pop()?.trim() === quietReply) {
ctx.quiet = true;
return null;
}
ctx.quiet = false;
return h("div", { class: "msg agent" }, markdown(String(p["text"]), "text md", md));
case "agent.thinking":
return h("details", { class: "thinking" }, h("summary", {}, subagentTag(parentOf(p), ctx), "thinking"), markdown(String(p["text"]), "text md", md));
case "agent.tool_result": {
const target = ctx.tools.get(String(p["tool_use_id"]));
const body = copyable(h("pre", {}, resultText(p["content"])));
if (!target) {
return h("div", { class: "tool-result orphan" }, body);
}
target.className = "tool-result";
replace(target, body);
return null;
}
case "turn.ended":
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: ", 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.created": {
if (typeof p["forked_from"] === "string") {
return h("div", { class: "note" }, `${ev.actor} forked this task from `, taskLink(p["forked_from"]), ` at snapshot #${String(p["forked_at"])}; its agent continues that task's conversation as it stood there `, when);
}
if (typeof p["parent_task_id"] === "string") {
return h("div", { class: "note" }, "subtask of ", taskLink(p["parent_task_id"]), ", created by its agent ", when);
}
return null;
}
case "task.parent_merge_started":
return h("div", { class: "note" }, `${ev.actor} asked `, taskLink(String(p["parent_task_id"])), "'s agent to merge this subtask into its branch ", when);
case "task.parent_merge_stopped":
return h("div", { class: "note error" }, "the merge into the parent task didn't start ", when);
case "task.merge_title":
return h("div", { class: "note" }, `${ev.actor} set the merge commit title to "${String(p["merge_title"])}" `, when);
case "review.finished": {
const r = p;
const outcome = r.state === "failed" ? `failed: ${r.error}` : r.state === "skipped" ? "skipped: no changes" : reviewCounts(r);
return h("div", { class: `note${r.state === "failed" ? " error" : ""}` }, `review of snapshot #${r.snapshot_n}: ${outcome} `, when);
}
case "task.merge_queued":
return h("div", { class: "note" }, `${ev.actor} asked to merge; held until the merge in progress in this project is done `, when);
case "task.merge_stopped":
return h("div", { class: "note" }, `${ev.actor} stopped the merge for review: ${String(p["reason"])} `, when);
case "preview.exposed": {
const url = previewURL(Number(p["port"]));
return h("div", { class: "note" }, `${ev.actor} exposed "${String(p["name"])}" at `, h("a", { href: url, target: "_blank", rel: "noopener" }, url), " ", when);
}
case "task.merged":
if (typeof p["parent_task_id"] === "string") {
return h("div", { class: "note" }, `${ev.actor} merged the task into `, taskLink(p["parent_task_id"]), `, now at ${String(p["commit"]).slice(0, 7)} `, when);
}
return h("div", { class: "note" }, `${ev.actor} merged the task${p["commit"] ? ` as ${String(p["commit"]).slice(0, 7)}` : ""}${p["pushed"] ? ` and pushed it to ${String(p["pushed"])}` : ""} `, when);
case "task.summary":
return h("div", { class: "note" }, `${ev.actor} set the task summary `, when);
case "task.discard_started":
return h("div", { class: "note" }, `${ev.actor} asked to discard the task; the agent writes the task summary first `, when);
case "task.discarded":
return h("div", { class: "note" }, `${ev.actor} discarded the task `, when);
}
return null;
}
function taskLink(id) {
const task = findTask(id);
if (task?.status !== "active") {
return document.createTextNode(task?.title ?? id);
}
return h("a", {
href: `#/task/${task.id}`,
class: "task-link",
style: taskStyle(task.id),
onclick: (e) => {
e.preventDefault();
focusTask(task.id);
},
}, task.title);
}
function messageImages(ids) {
if (!Array.isArray(ids) || ids.length === 0) {
return null;
}
return h("div", { class: "msg-images" }, ...ids.map((id) => {
const src = `/api/blobs/${encodeURIComponent(String(id))}`;
return h("a", { href: src, target: "_blank", rel: "noopener" }, h("img", { src, alt: "attached image", loading: "lazy" }));
}));
}
export async function confirmMerge(taskId) {
const task = findTask(taskId);
const sessionState = currentSession(taskId)?.state;
if (!task || task.status !== "active") {
return;
}
if (sessionState === "running") {
void showAlert("Agent is mid-turn; merge when it's idle");
return;
}
if (task.merge_queued_at !== undefined) {
void showAlert("Merge already held behind another merge in this project");
return;
}
if (task.merging_into_parent) {
void showAlert("Already waiting for the parent task's agent to merge this subtask");
return;
}
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
const parent = task.parent_task_id !== undefined ? findTask(task.parent_task_id) : undefined;
let coAuthors;
try {
const [unmerged, people] = await Promise.all([
get("/api/tasks/{taskId}/unmerged", { path: { taskId } }),
get("/api/tasks/{taskId}/co-authors", { path: { taskId } }),
]);
if (unmerged.commits === 0 && unmerged.files === 0) {
void showAlert(`Nothing to merge: no commits or uncommitted files differ from ${target}`);
return;
}
coAuthors = people;
}
catch (err) {
void showAlert(errorText(err));
return;
}
const title = mergeDrafts.get(taskId) ?? task.merge_title;
mergeDrafts.delete(taskId);
let input;
const edit = h("button", { type: "button", class: "merge-title-edit", title: "Edit the commit title", ariaLabel: "Edit the commit title" }, "✎");
const row = h("div", { class: "merge-title" }, h("span", { class: "merge-title-text" }, title), edit);
edit.onclick = () => {
const field = h("input", { type: "text", value: title, required: true, maxLength: 72, pattern: ".*\\S.*", ariaLabel: "Commit title" });
field.onkeydown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
row.closest("form")?.querySelector(".modal-actions button:last-child")?.click();
}
};
input = field;
replace(row, field);
field.focus();
field.select();
};
const warnings = h("ul", { class: "merge-warnings" });
const into = h("p", { class: "merge-into" }, `Merge into ${target} as:`);
const co = coAuthorsSummary(coAuthors);
const toMain = co ? [into, row, co] : [into, row];
let toParent = false;
const question = [];
if (parent?.status === "active") {
toParent = true;
const option = (value, label, hint) => {
const radio = h("input", { type: "radio", name: "merge-target", checked: value === toParent });
radio.onchange = () => {
toParent = value;
for (const n of toMain) {
n.hidden = toParent;
}
};
return h("label", { class: "merge-target" }, radio, h("span", {}, label, h("span", { class: "merge-target-hint" }, hint)));
};
question.push(h("div", { class: "merge-targets" }, option(true, `Into ${parent.title}`, "Its agent merges this subtask's branch into the parent's branch; the subtask closes once it has."), option(false, `Into ${target}`, "Squashed into one commit on the default branch and pushed, like any task.")));
for (const n of toMain) {
n.hidden = true;
}
}
question.push(...toMain.slice(0, 2), warnings, ...toMain.slice(2));
const leave = (go) => {
mergeDrafts.set(taskId, input?.value ?? title);
warnings.closest("dialog")?.close("cancel");
go();
};
const watch = watchMergeWarnings(taskId, warnings, leave);
await watch.ready;
try {
await confirmThen(question, "Merge", false, document.querySelector(".merge-button"), async () => {
const body = toParent ? { target: "parent" } : { merge_title: input?.value ?? title };
await post("/api/tasks/{taskId}/merge", { path: { taskId }, body });
});
}
finally {
watch.dispose();
}
}
const mergeDrafts = new Map();
function watchMergeWarnings(taskId, list, leave) {
let reviewItems = [];
let target = 0;
const render = () => {
const questions = state.inbox.filter((i) => i.task_id === taskId && i.kind === "question").length;
const items = [...reviewItems];
if (questions > 0) {
const text = questions === 1 ? "The agent's open question will be dismissed" : `The agent's ${questions} open questions will be dismissed`;
items.push(mergeWarning(text, () => leave(() => focusTask(taskId, "inbox"))));
}
replace(list, ...items);
list.hidden = items.length === 0;
};
const load = async () => {
const started = ++target;
let items;
try {
items = await reviewWarnings(taskId, leave);
}
catch (err) {
items = [mergeWarning("Review status unavailable", undefined, errorText(err))];
}
if (started === target) {
reviewItems = items;
render();
}
};
const offs = [
on("event", (detail) => {
const ev = detail;
if (ev.task_id === taskId && (ev.kind === "snapshot.created" || ev.kind.startsWith("review."))) {
void load();
}
}),
on("inbox", render),
];
return {
ready: load(),
dispose: () => {
target++;
for (const off of offs) {
off();
}
},
};
}
async function reviewWarnings(taskId, leave) {
const [snaps, reviews] = await Promise.all([get("/api/tasks/{taskId}/snapshots", { path: { taskId } }), get("/api/tasks/{taskId}/reviews", { path: { taskId } })]);
const latest = snaps.at(-1);
if (!latest) {
return [mergeWarning("These changes haven't been reviewed")];
}
const items = [];
const open = () => leave(() => {
focusTask(taskId, "reviews");
pickReview(latest.n);
});
const r = reviews.find((r) => r.snapshot_n === latest.n);
if (!r) {
items.push(mergeWarning("The latest snapshot hasn't been reviewed", open));
}
else if (r.state === "running") {
items.push(mergeWarning("Review of the latest snapshot is still running", open));
}
else if (r.state === "failed") {
items.push(mergeWarning("Review of the latest snapshot failed", open));
}
else if (unignoredFindings(r).length > 0) {
items.push(mergeWarning(`Review of the latest snapshot found ${severityCounts(unignoredFindings(r))}`, open));
}
const changes = await get("/api/tasks/{taskId}/changes", { path: { taskId }, query: { since: latest.commit_sha } });
if (changes.files.length > 0) {
items.push(mergeWarning("Changes since the last snapshot haven't been reviewed"));
}
return items;
}
function mergeWarning(text, open, detail) {
if (!open) {
return h("li", { class: "merge-warning", title: detail ?? "" }, `⚠ ${text}`);
}
const link = h("button", { type: "button", class: "merge-warning-link" }, text);
link.onclick = open;
return h("li", { class: "merge-warning" }, "⚠ ", link);
}
function coAuthorsSummary(coAuthors) {
if (coAuthors.length === 0) {
return null;
}
const missing = coAuthors.filter((p) => !p.git_name || !p.git_email).length;
const label = `${coAuthors.length} co-author${coAuthors.length === 1 ? "" : "s"}${missing > 0 ? ` (${missing} without git identity)` : ""}`;
return h("details", { class: "merge-coauthors" }, h("summary", {}, label), h("p", { class: "modal-text" }, mergeTrailers(coAuthors)));
}
function mergeTrailers(coAuthors) {
const lines = [];
const credited = coAuthors.filter((p) => p.git_name && p.git_email);
const missing = coAuthors.filter((p) => !p.git_name || !p.git_email);
if (credited.length > 0) {
lines.push("Co-authored-by:", ...credited.map((p) => ` ${p.git_name} <${p.git_email}>`));
}
if (missing.length > 0) {
if (lines.length > 0) {
lines.push("");
}
lines.push("No git identity, so no trailer:", ...missing.map((p) => ` ${p.display_name} (${p.login})`));
}
return lines.join("\n");
}
async function confirmThen(question, action, danger, anchor, act) {
if (!(await showConfirm(question, action, danger, anchor))) {
return;
}
try {
await act();
}
catch (err) {
await showAlert(errorText(err));
}
}
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