Files
byre/web/dist/task.js
T

1019 lines
40 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 } from "./reviews.js";
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, showReview, state } 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,
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",
empty: "No task selected. Ask your lead, or pick one with ⌘K.",
canSend: () => true,
placeholder: (s) => s === "running" ? "Message the agent; it reads this after its next tool call, or when its turn ends" : "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 merge = h("button", {
class: "merge-button",
disabled: busy || held || unmerged?.taskId !== task.id || !unmerged.has,
title: held
? "Waiting for the merge in progress in this project to finish"
: `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)`,
}, 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: "Delete 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 newCtx = () => ({
tools: new Map(),
subagents: new Map(),
reviewers: new Set(),
reviewButtons: new Map(),
run: undefined,
held: undefined,
drawn: undefined,
quiet: false,
handoff: undefined,
root: src.root,
taskId: src.taskId(),
});
let ctx = newCtx();
let context = new Map();
const headImpl = makeHead(() => renderHead());
const spinner = h("div", { class: "spinner", hidden: true });
const setRunning = (running) => {
transcript.classList.toggle("running", running);
if (running === !spinner.hidden) {
return;
}
spinner.hidden = !running;
pin();
};
const putBefore = (el, anchor) => {
if (anchor?.parentNode === transcript) {
transcript.insertBefore(el, anchor);
}
else if (spinner.parentNode === transcript) {
transcript.insertBefore(el, spinner);
}
else {
transcript.append(el);
}
};
const syncRunning = () => setRunning(src.session()?.state === "running" && !ctx.held);
const release = () => {
const el = ctx.held;
if (!el) {
return;
}
ctx.held = undefined;
ctx.drawn?.push(el);
putBefore(el, stream?.el ?? pending[0]?.el);
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.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();
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 old = composer.querySelector("textarea");
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, spinner);
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 run = ctx.run;
if (!run || run.parent !== parent) {
ctx.run = { first: el, group: undefined, calls: call ? [call] : [], parent };
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);
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));
};
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" }), 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") {
const r = p;
const button = ctx.reviewButtons.get(r.snapshot_n);
if (button) {
markReview(button, r);
}
}
if (ev.kind === "agent.tool_call" && isSubagentCall(p)) {
ctx.run = undefined;
put(renderSubagent(p, ctx));
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;
}
ctx.run = undefined;
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.drawn = [];
syncRunning();
continue;
}
put(el);
}
if (contextChanged) {
renderHead();
const sess = src.session();
if (sess && !sess.model && (context.get(sess.id)?.model ?? "") !== composerModel) {
renderComposer();
}
}
fillTodoLinks(transcript, models[0]);
pin();
};
const renderAll = () => {
stuck = true;
dropStream();
pending = [];
ctx = newCtx();
context = new Map();
focusStart = true;
headImpl.reset?.();
renderHead();
if (!src.key()) {
renderComposer();
replace(transcript, h("div", { class: "empty" }, src.empty));
return;
}
replace(transcript, spinner);
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(["tasks", "sessions"], () => fillTodoLinks(transcript, models[0])),
];
replace(root, h("div", { class: "transcript-view" }, head, transcript, composer));
renderAll();
return () => {
for (const off of offs) {
off();
}
headImpl.dispose?.();
resizes.disconnect();
mutations.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);
}
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) ?? [];
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(sessionState), rows: 3 });
const clearDraft = keepDraft(text, `message:${key}`);
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, `message:${key}`);
form.append(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(`message:${key}`)) {
return;
}
const imgs = tray.take();
text.value = "";
clearDraft();
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);
}
catch (err) {
cancel();
composerDrafts.set(`message:${key}`, value);
text.value = value;
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;
}
const sub = ctx.subagents.get(parent);
return h("span", { class: "subagent-tag", title: sub?.description ?? "" }, `↳ ${sub?.type ?? "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 tag = subagentTag(parentOf(p), ctx);
ctx.tools.set(id, result);
ctx.subagents.set(id, { type, description, status });
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 }), 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: "no findings" }, "✓"));
return;
}
replace(button, "review", h("span", { class: "badge", title: `${n} 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${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.created": {
if (typeof p["forked_from"] !== "string") {
return null;
}
const src = findTask(p["forked_from"]);
const title = src?.title ?? p["forked_from"];
const link = src?.status === "active"
? h("a", {
href: `#/task/${src.id}`,
class: "task-link",
style: taskStyle(src.id),
onclick: (e) => {
e.preventDefault();
focusTask(src.id);
},
}, title)
: title;
return h("div", { class: "note" }, `${ev.actor} forked this task from `, link, ` at snapshot #${String(p["forked_at"])}; its agent continues that task's conversation as it stood there `, 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":
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.discarded":
return h("div", { class: "note" }, `${ev.actor} discarded the task `, when);
}
return null;
}
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" || task.merge_queued_at !== undefined || sessionState === "running") {
return;
}
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
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) {
return;
}
coAuthors = people;
}
catch (err) {
void showAlert(errorText(err));
return;
}
const title = h("input", { type: "text", value: task.merge_title, required: true, maxLength: 72, pattern: ".*\\S.*", class: "modal-field", ariaLabel: "Commit title" });
const question = [h("p", { class: "modal-text" }, `Merge into ${target} as one commit titled:`), title];
const trailers = mergeTrailers(coAuthors);
if (trailers) {
question.push(h("p", { class: "modal-text" }, trailers));
}
await confirmThen(question, "Merge", false, document.querySelector(".merge-button"), async () => {
await post("/api/tasks/{taskId}/merge", { path: { taskId }, body: { merge_title: title.value } });
});
}
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