Files
byre/web/dist/task.js
T

918 lines
36 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, showAlert, showConfirm } from "./dom.js";
import { markdown } from "./markdown.js";
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, state } from "./state.js";
export const models = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"];
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: (s) => s === "idle",
placeholder: (s) => (s === "idle" ? "Message the agent. Paste or drop images to attach them." : "Wait for the turn to end, or interrupt it"),
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" || sess?.state === "queued";
const merge = h("button", { disabled: busy || unmerged?.taskId !== task.id || !unmerged.has, title: `The agent merges ${target} in, then byre squashes into one commit on ${target} and pushes it to origin` }, "Merge");
merge.onclick = async () => {
let coAuthors;
try {
coAuthors = await get("/api/tasks/{taskId}/co-authors", { path: { taskId: task.id } });
}
catch (err) {
void showAlert(errorText(err));
return;
}
await confirmThen(mergeQuestion(task.merge_title, target, coAuthors), "Merge", false, async () => {
await post("/api/tasks/{taskId}/merge", { path: { taskId: 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, 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" });
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 = () => ({
tools: new Map(),
subagents: new Map(),
run: undefined,
lastMessage: undefined,
turnStart: undefined,
keepTurnStart: false,
quiet: false,
handoff: undefined,
root: src.root(),
taskId: src.taskId(),
watchReport: (el, itemId) => {
el.dataset["inboxItem"] = itemId;
observer.observe(el);
},
});
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;
}
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
spinner.hidden = !running;
if (pinned) {
transcript.scrollTop = transcript.scrollHeight;
}
};
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 put = (el) => {
if (ctx.handoff) {
ctx.handoff.body.append(el);
return;
}
putBefore(el, pending[0]?.el);
};
const renderHead = () => {
setRunning(src.session()?.state === "running");
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, 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" }, "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);
transcript.scrollTop = transcript.scrollHeight;
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);
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) {
return;
}
if (!stream) {
stream = { el: h("div", { class: "msg agent" }), text: "", timer: 0 };
ctx.run = undefined;
put(stream.el);
}
stream.text += String(ev.payload["text"] ?? "");
if (stream.timer) {
return;
}
stream.timer = setTimeout(() => {
if (!stream) {
return;
}
stream.timer = 0;
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
stream.el.hidden = quietReply.startsWith(stream.text.trim());
replace(stream.el, markdown(stream.text, "text md", { taskId: ctx.taskId }));
if (pinned) {
transcript.scrollTop = transcript.scrollHeight;
}
}, 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) => {
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
let contextChanged = false;
headImpl.onEvents?.(events);
for (const ev of events) {
const p = ev.payload;
const parent = parentOf(p);
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.taskId));
continue;
}
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: 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.turnStart?.parentNode === transcript) {
let node = ctx.keepTurnStart ? ctx.turnStart.nextSibling : ctx.turnStart;
while (node && node !== spinner) {
const next = node.nextSibling;
node.remove();
node = next;
}
ctx.turnStart = undefined;
ctx.quiet = false;
continue;
}
put(el);
if (ev.kind === "user.message" && p["mid_turn"] !== true) {
ctx.turnStart = el;
ctx.keepTurnStart = p["continuation"] === true;
}
}
if (contextChanged) {
renderHead();
const sess = src.session();
if (sess && !sess.model && (context.get(sess.id)?.model ?? "") !== composerModel) {
renderComposer();
}
}
if (pinned) {
transcript.scrollTop = transcript.scrollHeight;
}
};
const renderAll = () => {
observer.disconnect();
visibleReports.clear();
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(["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);
headImpl.dispose?.();
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);
}
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 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 composeForm(src, key, sessionId, sessionState, currentModel, 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" && sessionState !== "queued" }, sessionState === "queued" ? "Cancel queued turn" : "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);
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" }, 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 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 snapRow(snap, 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 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);
}
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.lastMessage = undefined;
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" }, 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() === quietReply) {
ctx.quiet = true;
return null;
}
ctx.quiet = false;
ctx.lastMessage = h("div", { class: "msg agent" }, markdown(String(p["text"]), "text md", md));
return ctx.lastMessage;
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 = 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 (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.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}`,
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 "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" }));
}));
}
function mergeQuestion(title, target, coAuthors) {
const lines = [`Merge "${title}" into ${target}?`];
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) {
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, act) {
if (!(await showConfirm(question, action, danger))) {
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