Files
byre/web/src/task.ts
T

839 lines
28 KiB
TypeScript
Raw Normal View History

import { get, post, type ByreEvent } from "./api.js";
import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
2026-10-14 19:35:34 -07:00
import { markdown } from "./markdown.js";
import { currentSession, findTask, onEach, state } from "./state.js";
2026-10-14 19:35:34 -07:00
const models = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"];
type Call = { name: string; target: string };
type Run = {
first: HTMLElement;
group: { el: HTMLDetailsElement; summary: HTMLElement; body: HTMLElement } | undefined;
calls: Call[];
};
type Ctx = {
tools: Map<string, HTMLElement>;
run: Run | undefined;
root: string;
2026-10-14 19:35:34 -07:00
lastMessage: HTMLElement | undefined;
turnStart: HTMLElement | undefined;
watchReport: (el: HTMLElement, itemId: string) => void;
};
export function mountTranscript(root: HTMLElement): () => void {
const head = h("header", { class: "task-header" });
const transcript = h("div", { class: "transcript" });
const composer = h("div", { class: "composer" });
const visibleReports = new Map<Element, string>();
const acked = new Set<string>();
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" as const })) } }).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 as HTMLElement).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 = (): Ctx => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
return {
tools: new Map(),
run: undefined,
2026-10-14 19:35:34 -07:00
lastMessage: undefined,
turnStart: undefined,
root: task?.worktree_path ?? "",
watchReport: (el, itemId) => {
el.dataset["inboxItem"] = itemId;
observer.observe(el);
},
};
};
let ctx = newCtx();
let context = new Map<string, { used: number; window: number; model: string }>();
let unmerged: { taskId: string; has: boolean } | undefined;
let unmergedTimer: number | undefined;
const loadUnmerged = 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 };
renderHead();
} catch (err) {
console.error("load unmerged changes", err);
}
};
const spinner = h("div", { class: "spinner", hidden: true });
const setRunning = (running: boolean) => {
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: HTMLElement, anchor: HTMLElement | undefined) => {
if (anchor?.parentNode === transcript) {
transcript.insertBefore(el, anchor);
} else if (spinner.parentNode === transcript) {
transcript.insertBefore(el, spinner);
} else {
transcript.append(el);
}
};
const put = (el: HTMLElement) => putBefore(el, pending[0]?.el);
const renderHead = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
setRunning(task !== undefined && currentSession(task.id)?.state === "running");
if (!task) {
replace(head);
return;
}
const sess = currentSession(task.id);
2026-10-14 19:35:34 -07:00
if (task.status !== "active") {
2026-10-14 21:01:38 -07:00
replace(head, h("div", { class: "head-actions" }, task.status));
2026-10-14 19:35:34 -07:00
return;
}
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
2026-10-14 21:01:38 -07:00
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` },
2026-10-14 21:01:38 -07:00
"Merge",
);
2026-10-14 19:35:34 -07:00
merge.onclick = () =>
confirmThen(`Merge "${task.title}" into ${target}?`, "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}`);
2026-10-14 21:01:38 -07:00
mergeIn.onclick = async () => {
mergeIn.disabled = true;
try {
await post("/api/tasks/{taskId}/merge-in", { path: { taskId: task.id } });
} catch (err) {
void showAlert(errorText(err));
2026-10-14 21:01:38 -07:00
mergeIn.disabled = false;
}
};
const discard = h("button", { class: "danger", disabled: busy, title: "Delete the worktree and branch without merging" }, "Discard");
2026-10-14 19:35:34 -07:00
discard.onclick = () =>
confirmThen(`Discard "${task.title}"?`, "Discard", true, async () => {
2026-10-14 21:01:38 -07:00
await post("/api/tasks/{taskId}/discard", { path: { taskId: task.id } });
location.hash = "#/";
});
replace(
head,
h("div", { class: "head-actions session-actions" }, compactButton(sess?.id, sess?.state, sess && context.get(sess.id)), mergeIn),
2026-10-14 21:01:38 -07:00
h("div", { class: "head-actions" }, merge, discard),
);
};
2026-10-14 19:35:34 -07:00
let composerModel = "";
let focusStart = false;
const renderComposer = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
if (!task) {
replace(composer);
return;
}
const sess = currentSession(task.id);
const live = sess && sess.state !== "completed" ? sess : undefined;
composerModel = live ? live.model || context.get(live.id)?.model || "" : "";
const form = composeForm(task.id, 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: { el: HTMLElement; text: string; imgs: PendingImage[] }[] = [];
const showPending = (text: string, imgs: PendingImage[]) => {
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: string) => {
const entry = pending.find((p) => p.text === text);
if (!entry) {
return;
}
entry.el.remove();
releaseImages(entry.imgs);
pending = pending.filter((p) => p !== entry);
};
const addToRun = (el: HTMLElement, call: Call | undefined) => {
const run = ctx.run;
if (!run) {
ctx.run = { first: el, group: undefined, calls: call ? [call] : [] };
put(el);
return;
}
if (call) {
run.calls.push(call);
}
if (!run.group) {
const summary = h("summary");
const body = h("div", { class: "tool-group-body" });
const group = h("details", { class: "tool-group" }, summary, body);
run.first.replaceWith(group);
body.append(run.first);
run.group = { el: group, summary, body };
}
run.group.body.append(el);
replace(run.group.summary, runSummary(run.calls));
};
let stream: { el: HTMLElement; text: string; timer: number } | undefined;
2026-10-14 19:35:34 -07:00
const dropStream = () => {
if (!stream) {
return;
}
clearTimeout(stream.timer);
2026-10-14 19:35:34 -07:00
stream.el.remove();
stream = undefined;
};
const appendDelta = (ev: ByreEvent) => {
if (!stream) {
stream = { el: h("div", { class: "msg agent" }), text: "", timer: 0 };
2026-10-14 19:35:34 -07:00
ctx.run = undefined;
put(stream.el);
}
stream.text += String(ev.payload["text"] ?? "");
if (stream.timer) {
2026-10-14 19:35:34 -07:00
return;
}
stream.timer = setTimeout(() => {
2026-10-14 19:35:34 -07:00
if (!stream) {
return;
}
stream.timer = 0;
2026-10-14 19:35:34 -07:00
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
replace(stream.el, markdown(stream.text, "text md"));
if (pinned) {
transcript.scrollTop = transcript.scrollHeight;
}
}, 100);
2026-10-14 19:35:34 -07:00
};
const foldTurn = (start: HTMLElement, end: HTMLElement) => {
const summary = h("summary");
summary.append(...end.childNodes);
const body = h("div", { class: "folded-turn" });
start.before(h("details", { class: "note" }, summary, body));
let node: ChildNode | null = start;
while (node && node !== spinner) {
const next: ChildNode | null = node.nextSibling;
body.append(node);
node = next;
}
};
const appendEvents = (events: ByreEvent[]) => {
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
let contextChanged = false;
for (const ev of events) {
const p = ev.payload as Record<string, unknown>;
if (ev.kind === "agent.tool_result" || ev.kind === "turn.ended") {
clearTimeout(unmergedTimer);
unmergedTimer = setTimeout(() => void loadUnmerged(), 1500);
}
2026-10-14 19:35:34 -07:00
if (ev.kind === "agent.message" || 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 === "agent.tool_call") {
addToRun(renderToolCall(p, ctx), { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) });
continue;
}
const el = renderEvent(ev, ctx);
if (!el) {
continue;
}
if (ev.kind === "agent.thinking") {
addToRun(el, undefined);
continue;
}
ctx.run = undefined;
2026-10-14 19:35:34 -07:00
if (ev.kind === "turn.ended" && p["handoff"] === true && ctx.turnStart?.parentNode === transcript) {
foldTurn(ctx.turnStart, el);
ctx.turnStart = undefined;
continue;
}
put(el);
2026-10-14 19:35:34 -07:00
if (ev.kind === "user.message") {
ctx.turnStart = el;
}
}
if (contextChanged) {
renderHead();
2026-10-14 19:35:34 -07:00
const sess = state.selectedTaskId ? currentSession(state.selectedTaskId) : undefined;
if (sess && !sess.model && (context.get(sess.id)?.model ?? "") !== composerModel) {
renderComposer();
}
}
if (pinned) {
transcript.scrollTop = transcript.scrollHeight;
}
};
const renderAll = () => {
observer.disconnect();
visibleReports.clear();
2026-10-14 19:35:34 -07:00
dropStream();
pending = [];
ctx = newCtx();
context = new Map();
focusStart = true;
clearTimeout(unmergedTimer);
void loadUnmerged();
renderHead();
if (!state.selectedTaskId) {
2026-10-14 19:35:34 -07:00
renderComposer();
replace(transcript, h("div", { class: "empty" }, "No task selected. Pick one with ⌘K or from the Tasks tab."));
return;
}
replace(transcript, spinner);
appendEvents(state.transcript);
2026-10-14 19:35:34 -07:00
renderComposer();
};
const offs = [
onEach(["selection"], renderAll),
onEach(["tasks"], () => {
renderHead();
renderComposer();
}),
onEach(["sessions"], (taskId) => {
if (taskId === state.selectedTaskId) {
renderHead();
renderComposer();
}
}),
onEach(["transcript"], (fresh) => appendEvents(fresh as ByreEvent[])),
2026-10-14 19:35:34 -07:00
onEach(["stream"], (ev) => appendDelta(ev as ByreEvent)),
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);
clearTimeout(unmergedTimer);
observer.disconnect();
2026-10-14 19:35:34 -07:00
dropStream();
};
}
const composerDrafts = new Map<string, string>();
function keepDraft(input: HTMLTextAreaElement, key: string): () => void {
input.value = composerDrafts.get(key) ?? "";
input.addEventListener("input", () => {
if (input.value) {
composerDrafts.set(key, input.value);
} else {
composerDrafts.delete(key);
}
});
return () => composerDrafts.delete(key);
}
2026-10-14 20:07:26 -07:00
type PendingImage = { file: Blob; url: string };
const imageDrafts = new Map<string, PendingImage[]>();
const imageTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
const maxImageBytes = 5 << 20;
function imageTray(form: HTMLFormElement, input: HTMLTextAreaElement, key: string): { el: HTMLElement; take: () => PendingImage[]; restore: (imgs: PendingImage[]) => void } {
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: Iterable<File>) => {
const added: PendingImage[] = [];
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.`);
2026-10-14 20:07:26 -07:00
continue;
}
if (f.size > maxImageBytes) {
void showAlert(`${f.name || "That image"} is over ${maxImageBytes >> 20} MiB.`);
2026-10-14 20:07:26 -07:00
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 as Node | null)) {
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: PendingImage[]): Promise<string[] | undefined> {
if (imgs.length === 0) {
return undefined;
}
const ids: string[] = [];
for (const img of imgs) {
const blob = await post("/api/blobs", { body: img.file });
ids.push(blob.id);
}
return ids;
}
function releaseImages(imgs: PendingImage[]): void {
for (const img of imgs) {
URL.revokeObjectURL(img.url);
}
}
2026-10-14 19:35:34 -07:00
const modelChoices = new Map<string, string>();
function modelSelect(key: string, current: string): HTMLSelectElement {
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(
taskId: string,
sessionId: string | undefined,
sessionState: string,
currentModel: string,
showPending: (text: string, imgs: PendingImage[]) => () => void,
): HTMLElement {
2026-10-14 20:07:26 -07:00
const text = h("textarea", {
placeholder: sessionState === "idle" ? "Message the agent. Paste or drop images to attach them." : "Wait for the turn to end, or interrupt it",
rows: 3,
});
const clearDraft = keepDraft(text, `message:${taskId}`);
const send = h("button", { type: "submit", disabled: sessionState !== "idle" }, "Send");
const interrupt = h(
"button",
{ type: "button", class: "danger", disabled: sessionState !== "running" && sessionState !== "queued" },
sessionState === "queued" ? "Cancel queued turn" : "Interrupt",
);
interrupt.onclick = async () => {
if (!sessionId) {
return;
}
try {
await post("/api/sessions/{sessionId}/interrupt", { path: { sessionId } });
} catch (err) {
void showAlert(errorText(err));
}
};
const modelKey = `message:${sessionId ?? taskId}`;
const model = modelSelect(modelKey, currentModel);
2026-10-14 20:07:26 -07:00
const form = h("form", { class: "compose" });
const tray = imageTray(form, text, `message:${taskId}`);
form.append(tray.el, text, h("div", { class: "compose-actions" }, model, interrupt, send));
form.onsubmit = async (e) => {
e.preventDefault();
2026-10-14 19:35:34 -07:00
const value = text.value;
2026-10-14 20:07:26 -07:00
if (!value.trim() && !imageDrafts.has(`message:${taskId}`)) {
return;
}
2026-10-14 20:07:26 -07:00
const imgs = tray.take();
2026-10-14 19:35:34 -07:00
text.value = "";
clearDraft();
const cancel = showPending(value, imgs);
try {
2026-10-14 20:07:26 -07:00
const images = await uploadImages(imgs);
if (sessionId) {
await post("/api/sessions/{sessionId}/messages", {
path: { sessionId },
body: { text: value, images, model: model.value !== currentModel ? model.value : undefined },
});
} else {
await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: value, model: model.value, images } });
}
modelChoices.delete(modelKey);
} catch (err) {
cancel();
2026-10-14 19:35:34 -07:00
composerDrafts.set(`message:${taskId}`, value);
text.value = value;
2026-10-14 20:07:26 -07:00
tray.restore(imgs);
void showAlert(errorText(err));
}
};
submitOnEnter(text, form);
return form;
}
function submitOnEnter(input: HTMLTextAreaElement, form: HTMLFormElement): void {
input.onkeydown = (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
form.requestSubmit();
}
};
}
function compactButton(sessionId: string | undefined, sessionState: string | undefined, c: { used: number; window: number; model: string } | undefined): HTMLElement {
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})` : ""}` : "";
2026-10-14 19:35:34 -07:00
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}`,
},
2026-10-14 19:35:34 -07:00
"Compact",
);
b.onclick = async () => {
if (!sessionId) {
return;
}
2026-10-14 19:35:34 -07:00
b.disabled = true;
try {
await post("/api/sessions/{sessionId}/compact", { path: { sessionId } });
} catch (err) {
void showAlert(errorText(err));
2026-10-14 19:35:34 -07:00
b.disabled = false;
}
};
return b;
}
function renderToolCall(p: Record<string, unknown>, ctx: Ctx): HTMLElement {
const result = h("div", { class: "tool-result pending" }, "running…");
ctx.tools.set(String(p["id"]), result);
return h(
"details",
{ class: "tool" },
h("summary", {}, h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"], ctx.root))),
h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2)),
result,
);
}
function runSummary(calls: Call[]): HTMLElement {
const counts = new Map<string, { call: Call; n: number }>();
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: (HTMLElement | string)[] = [];
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: ByreEvent, ctx: Ctx): HTMLElement | null {
const p = ev.payload as Record<string, unknown>;
const when = h("time", { dateTime: ev.created_at, title: ev.created_at }, ago(ev.created_at));
switch (ev.kind) {
case "user.message":
2026-10-14 19:35:34 -07:00
ctx.lastMessage = undefined;
if (ev.actor === "byre") {
2026-10-14 19:35:34 -07:00
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"),
);
}
2026-10-14 20:07:26 -07:00
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") : null,
);
case "agent.message":
2026-10-14 19:35:34 -07:00
ctx.lastMessage = h("div", { class: "msg agent" }, markdown(String(p["text"]), "text md"));
return ctx.lastMessage;
case "agent.thinking":
return h("details", { class: "thinking" }, h("summary", {}, "thinking"), markdown(String(p["text"]), "text md"));
case "agent.tool_result": {
const target = ctx.tools.get(String(p["tool_use_id"]));
const body = h("pre", {}, resultText(p["content"]));
if (!target) {
return h("div", { class: "tool-result orphan" }, body);
}
2026-10-14 21:16:05 -07:00
target.className = "tool-result";
replace(target, body);
return null;
}
case "turn.ended":
2026-10-14 19:35:34 -07:00
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);
2026-10-14 19:35:34 -07:00
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"] as Record<string, unknown>;
return h("div", { class: "note ask" }, `asked${p["blocking"] ? " (blocking)" : ""}: `, String(q["question"]));
}
if (kind === "session_error" || kind === "rate_limited") {
const q = p["payload"] as Record<string, unknown>;
return h("div", { class: "note error" }, String(q["error"]));
}
return null;
}
case "task.merge_title":
return h("div", { class: "note" }, `${ev.actor} set the merge commit title to "${String(p["merge_title"])}" `, when);
2026-10-14 19:35:34 -07:00
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,
);
2026-10-14 19:35:34 -07:00
case "task.discarded":
return h("div", { class: "note" }, `${ev.actor} discarded the task `, when);
}
return null;
}
2026-10-14 20:07:26 -07:00
function messageImages(ids: unknown): HTMLElement | null {
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" }));
}),
);
}
async function confirmThen(question: string, action: string, danger: boolean, act: () => Promise<void>) {
if (!(await showConfirm(question, action, danger))) {
return;
}
2026-10-14 19:35:34 -07:00
try {
2026-10-14 21:01:38 -07:00
await act();
2026-10-14 19:35:34 -07:00
} catch (err) {
await showAlert(errorText(err));
2026-10-14 19:35:34 -07:00
}
}
function toolBrief(input: unknown, root: string): string {
if (typeof input !== "object" || input === null) {
return "";
}
const i = input as Record<string, unknown>;
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: unknown, root: string): string {
if (typeof input !== "object" || input === null) {
return "";
}
const path = (input as Record<string, unknown>)["file_path"];
return typeof path === "string" ? relative(path, root) : "";
}
function relative(path: string, root: string): string {
return root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
}
function resultText(content: unknown): string {
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);
}