Files
byre/web/src/task.ts
T

1557 lines
53 KiB
TypeScript
Raw Normal View History

import { del, get, post, type AgentSession, type ByreEvent, type Participant, type Schemas, type Snapshot } from "./api.js";
import { ago, copyable, errorText, h, replace, showAlert, showChoice, showConfirm } from "./dom.js";
2026-10-14 19:35:34 -07:00
import { markdown } from "./markdown.js";
import { previewURL } from "./preview.js";
2026-10-16 10:44:27 -07:00
import { findingCount, reviewCounts, severityCounts, unignoredFindings } from "./reviews.js";
2026-10-16 08:42:48 -07:00
import { composerText, endSend, flushDrafts, forgetLostImages, lostImageCount, setComposerImages, setComposerText, startSend } from "./drafts.js";
import { imageDrafts, imageTray, releaseImages, uploadImages, type PendingImage } from "./images.js";
import { currentSession, findTask, focusTab, focusTask, markSummaryRead, on, onEach, pickReview, pinSnapshot, setFocus, showReview, state, transcriptLoading, type Topic } from "./state.js";
import { taskStyle } from "./taskcolor.js";
2026-10-15 20:20:02 -07:00
import { fillTodoLinks } from "./todolink.js";
export type Usage = { used: number; window: number; model: string };
type Review = Schemas["Review"];
export type ChatSource = {
key: () => string | undefined;
events: () => ByreEvent[];
loading: () => boolean;
session: () => AgentSession | undefined;
root: () => string;
taskId: () => string | undefined;
resetTopics: Topic[];
refreshTopics: Topic[];
transcriptTopic: Topic;
streamTopic: Topic;
readyTopic: Topic;
older?: { has: () => boolean; load: () => Promise<ByreEvent[]> };
empty: string;
canSend: (sessionState: string) => boolean;
2026-10-15 21:56:59 -07:00
placeholder: string;
send: (m: { key: string; sessionId: string | undefined; text: string; images: string[] | undefined; model: string | undefined; firstModel: string }) => Promise<void>;
};
export type Head = {
render: (usage: (sessionId: string) => Usage | undefined) => (Node | false | null)[];
onEvents?: (events: ByreEvent[]) => void;
reset?: () => void;
dispose?: () => void;
};
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";
type Call = { name: string; target: string };
type Run = {
first: HTMLElement;
group: { el: HTMLDetailsElement; summary: HTMLElement; body: HTMLElement } | undefined;
calls: Call[];
parent: string;
};
type Subagent = {
type: string;
description: string;
status: HTMLElement;
2026-10-16 11:26:15 -07:00
body: HTMLElement;
run: Run | undefined;
2026-10-15 00:08:32 -07:00
};
type Ctx = {
tools: Map<string, HTMLElement>;
subagents: Map<string, Subagent>;
reviewers: Set<string>;
reviewButtons: Map<number, HTMLElement>;
run: Run | undefined;
root: () => string;
taskId: string | undefined;
held: DocumentFragment | undefined;
2026-10-15 20:41:42 -07:00
open: boolean;
drawn: HTMLElement[] | undefined;
quiet: boolean;
handoff: { summary: HTMLElement; body: HTMLElement } | undefined;
};
const taskSource: ChatSource = {
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,
2026-10-15 21:56:59 -07:00
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: HTMLElement): () => void {
return mountChat(root, taskSource, taskHead);
}
function taskHead(rerender: () => void): Head {
let unmerged: { taskId: string; has: boolean } | undefined;
let timer: number | undefined;
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;
2026-10-16 10:36:38 -07:00
const intoParent = task.merging_into_parent;
const merge = h(
"button",
{
class: "merge-button",
2026-10-16 10:36:38 -07:00
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.merge_after_review
? "Merges when the running review finishes with no findings; open to merge now instead (⌘S)"
: task.parent_task_id !== undefined
2026-10-16 10:36:38 -07:00
? `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" : task.merge_after_review ? "Merge after review" : "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: HTMLElement, src: ChatSource, makeHead: (rerender: () => void) => Head): () => void {
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<HTMLElement>();
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 as HTMLElement);
} else {
shown.delete(e.target as HTMLElement);
}
}
markShown();
},
{ root: transcript },
);
document.addEventListener("visibilitychange", markShown);
const jump = () => {
const target = state.jump;
if (!target || target.taskId !== ctx.taskId) {
return;
}
const end = transcript.querySelector<HTMLElement>(`.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 = (): Ctx => ({
tools: new Map(),
subagents: new Map(),
reviewers: new Set(),
reviewButtons: new Map(),
run: undefined,
held: undefined,
2026-10-15 20:41:42 -07:00
open: false,
drawn: undefined,
quiet: false,
handoff: undefined,
root: src.root,
taskId: src.taskId(),
});
let ctx = newCtx();
let context = new Map<string, Usage>();
const headImpl = makeHead(() => renderHead());
const scanner = h("div", { class: "scanner", hidden: true });
const loading = h("div", { class: "empty" }, "loading…");
const earlier = h("div");
let sink: DocumentFragment | undefined;
const setRunning = (running: boolean) => {
transcript.classList.toggle("running", running);
scanner.hidden = !running;
};
const putBefore = (el: Node, anchor: HTMLElement | undefined) => {
if (sink) {
sink.append(el);
return;
}
if (anchor?.parentNode === transcript) {
transcript.insertBefore(el, anchor);
} else {
2026-10-16 08:34:06 -07:00
transcript.insertBefore(el, scanner);
}
};
2026-10-15 20:41:42 -07:00
const syncRunning = () => setRunning(src.session()?.state === "running" && ctx.open);
const release = () => {
const buffered = ctx.held;
if (!buffered) {
return;
}
ctx.held = undefined;
2026-10-15 20:41:42 -07:00
ctx.open = true;
putBefore(buffered, pending[0]?.el);
syncRunning();
};
const put = (el: HTMLElement, keepHeld = false) => {
if (ctx.handoff) {
ctx.handoff.body.append(el);
return;
}
if (!keepHeld) {
release();
}
ctx.drawn?.push(el);
if (ctx.held) {
ctx.held.append(el);
return;
}
putBefore(el, pending[0]?.el);
};
const fold = () => {
for (const el of ctx.drawn ?? []) {
el.remove();
}
ctx.drawn = undefined;
ctx.held = undefined;
2026-10-15 20:41:42 -07:00
ctx.open = false;
ctx.quiet = false;
syncRunning();
};
const renderHead = () => {
syncRunning();
if (!src.key()) {
replace(head);
return;
}
replace(head, ...headImpl.render((id) => context.get(id)));
};
2026-10-14 19:35:34 -07:00
let composerModel = "";
let focusStart = false;
const renderComposer = () => {
const key = src.key();
2026-10-16 08:42:48 -07:00
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: { el: HTMLElement; text: string; imgs: PendingImage[] }[] = [];
const showPending = (text: string, imgs: PendingImage[]) => {
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: 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 runHead = (run: Run) => [subagentTag(run.parent, ctx), runSummary(run.calls)];
const addToRun = (el: HTMLElement, call: Call | undefined, parent: string) => {
2026-10-16 11:26:15 -07:00
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, (e) => put(e, true));
2026-10-16 11:26:15 -07:00
};
const extendRun = (run: Run | undefined, el: HTMLElement, call: Call | undefined, parent: string, place: (el: HTMLElement) => void): Run => {
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));
2026-10-16 11:26:15 -07:00
return run;
2026-10-15 00:08:32 -07:00
};
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 (ctx.handoff || (ev.agent_session_id && ctx.reviewers.has(ev.agent_session_id))) {
return;
}
2026-10-14 19:35:34 -07:00
if (!stream) {
2026-10-15 20:41:42 -07:00
stream = { el: h("div", { class: "msg agent", hidden: true }), text: "", timer: 0 };
2026-10-14 19:35:34 -07:00
ctx.run = undefined;
put(stream.el, true);
2026-10-14 19:35:34 -07:00
}
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;
stream.el.hidden = ctx.held !== undefined || quietReply.startsWith(stream.text.trim());
replace(stream.el, markdown(stream.text, "text md", { taskId: ctx.taskId }));
2026-10-15 20:20:02 -07:00
fillTodoLinks(stream.el, models[0]!);
pin();
}, 100);
2026-10-14 19:35:34 -07:00
};
const openHandoff = () => {
const summary = h("summary", {}, "writing a handoff…");
2026-10-14 19:35:34 -07:00
const body = h("div", { class: "folded-turn" });
put(h("details", { class: "note" }, summary, body));
ctx.handoff = { summary, body };
};
const closeHandoff = (end: HTMLElement | undefined): HTMLElement | undefined => {
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");
2026-10-14 19:35:34 -07:00
}
return handoff.summary;
2026-10-14 19:35:34 -07:00
};
const renderEvents = (events: ByreEvent[]): boolean => {
let contextChanged = false;
for (const ev of events) {
const p = ev.payload as Record<string, unknown>;
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 (!sink && ((ev.kind === "agent.message" && !parent) || ev.kind === "turn.ended" || ev.kind === "user.message")) {
2026-10-14 19:35:34 -07:00
dropStream();
}
if (!sink && ev.kind === "user.message" && ev.actor !== "byre") {
confirmPending(String(p["text"] ?? ""));
}
if (ev.kind === "session.context" && sink) {
continue;
}
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;
}
2026-10-15 00:08:32 -07:00
if (ev.kind === "snapshot.created") {
ctx.run = undefined;
put(snapRow(p as unknown as Snapshot, ctx));
2026-10-15 00:08:32 -07:00
continue;
}
2026-10-16 10:44:27 -07:00
if (ev.kind === "review.started" || ev.kind === "review.finished" || ev.kind === "review.finding") {
const r = p as unknown as Review;
const button = ctx.reviewButtons.get(r.snapshot_n);
if (button) {
2026-10-15 20:07:36 -07:00
markReview(button, r);
}
}
2026-10-16 11:26:15 -07:00
const sub = ctx.subagents.get(parent);
if (ev.kind === "agent.tool_call" && isSubagentCall(p)) {
2026-10-16 11:26:15 -07:00
const el = renderSubagent(p, ctx);
if (sub) {
sub.run = undefined;
sub.body.append(el);
continue;
}
ctx.run = undefined;
put(el, true);
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;
}
2026-10-16 11:26:15 -07:00
if (sub) {
sub.run = undefined;
sub.body.append(el);
continue;
}
ctx.run = undefined;
if (ev.kind === "agent.message" && !parent) {
ctx.drawn = undefined;
}
2026-10-15 20:41:42 -07:00
if (ev.kind === "turn.ended") {
ctx.open = false;
}
if (ev.kind === "turn.ended" && p["handoff"] === true && ctx.handoff) {
closeHandoff(el);
2026-10-14 19:35:34 -07:00
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 = document.createDocumentFragment();
2026-10-15 20:41:42 -07:00
ctx.open = false;
ctx.drawn = [];
continue;
2026-10-14 19:35:34 -07:00
}
2026-10-15 20:41:42 -07:00
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);
}
return contextChanged;
};
const appendEvents = (events: ByreEvent[]) => {
headImpl.onEvents?.(events);
const contextChanged = renderEvents(events);
2026-10-15 20:41:42 -07:00
syncRunning();
if (contextChanged) {
renderHead();
const sess = src.session();
2026-10-14 19:35:34 -07:00
if (sess && !sess.model && (context.get(sess.id)?.model ?? "") !== composerModel) {
renderComposer();
}
}
2026-10-15 20:20:02 -07:00
fillTodoLinks(transcript, models[0]!);
syncMergeAfterReview(transcript);
pin();
jump();
};
const prependEvents = (events: ByreEvent[]) => {
const live = ctx;
const fromBottom = transcript.scrollHeight - transcript.scrollTop;
const box = document.createDocumentFragment();
ctx = newCtx();
sink = box;
renderEvents(events);
sink = undefined;
ctx = live;
syncRunning();
fillTodoLinks(box, models[0]!);
earlier.after(box);
syncMergeAfterReview(transcript);
transcript.scrollTop = transcript.scrollHeight - fromBottom;
};
let disposed = false;
let fetching = false;
const loadOlder = async () => {
const older = src.older;
if (!older || fetching || !older.has()) {
return;
}
fetching = true;
replace(earlier, h("div", { class: "empty" }, "loading earlier…"));
try {
const events = await older.load();
replace(earlier);
if (!disposed) {
prependEvents(events);
}
} finally {
fetching = false;
replace(earlier);
}
watchTop();
};
const top = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
loadOlder().catch((err: unknown) => console.error("load earlier events", err));
}
},
{ root: transcript, rootMargin: "400px 0px 0px 0px" },
);
const watchTop = () => {
if (disposed) {
return;
}
top.unobserve(earlier);
top.observe(earlier);
};
const renderAll = () => {
stuck = true;
2026-10-14 19:35:34 -07:00
dropStream();
pending = [];
sightings.disconnect();
shown.clear();
ctx = newCtx();
context = new Map();
focusStart = true;
headImpl.reset?.();
renderHead();
if (!src.key()) {
2026-10-14 19:35:34 -07:00
renderComposer();
2026-10-16 08:34:06 -07:00
replace(transcript, h("div", { class: "empty" }, src.empty), scanner);
return;
}
replace(transcript, earlier, scanner);
if (src.loading()) {
transcript.prepend(loading);
}
appendEvents(src.events());
2026-10-14 19:35:34 -07:00
renderComposer();
watchTop();
};
const offs = [
onEach(src.resetTopics, renderAll),
onEach(src.refreshTopics, () => {
renderHead();
renderComposer();
}),
onEach([src.transcriptTopic], (fresh) => appendEvents(fresh as ByreEvent[])),
onEach([src.streamTopic], (ev) => appendDelta(ev as ByreEvent)),
onEach([src.readyTopic], () => {
loading.remove();
watchTop();
}),
onEach(["tasks", "sessions"], () => {
fillTodoLinks(transcript, models[0]!);
syncMergeAfterReview(transcript);
}),
on("jump", jump),
];
2026-10-16 08:34:06 -07:00
replace(root, h("div", { class: "transcript-view" }, head, transcript, composer));
renderAll();
return () => {
for (const off of offs) {
off();
}
disposed = true;
top.disconnect();
headImpl.dispose?.();
resizes.disconnect();
mutations.disconnect();
sightings.disconnect();
document.removeEventListener("visibilitychange", markShown);
2026-10-14 19:35:34 -07:00
dropStream();
2026-10-16 08:42:48 -07:00
const input = composer.querySelector("textarea");
if (input) {
releaseInput(input);
}
flushDrafts();
};
}
2026-10-16 08:42:48 -07:00
const composerInputs = new Map<string, HTMLTextAreaElement>();
2026-10-16 08:42:48 -07:00
function releaseInput(input: HTMLTextAreaElement): void {
for (const [key, el] of composerInputs) {
if (el === input) {
composerInputs.delete(key);
}
2026-10-16 08:42:48 -07:00
}
}
function keepDraft(input: HTMLTextAreaElement, key: string): void {
composerInputs.set(key, input);
input.value = composerText(key);
input.addEventListener("input", () => setComposerText(key, input.value));
input.addEventListener("blur", flushDrafts);
}
function lostImagesNote(key: string): HTMLElement | null {
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;
}
2026-10-14 19:35:34 -07:00
const modelChoices = new Map<string, string>();
function vendorOf(model: string): string {
return model.startsWith("gpt-") ? "codex" : "claude";
}
function modelSelect(key: string, current: string, vendor: string | undefined): HTMLSelectElement {
const offered = vendor ? models.filter((m) => vendorOf(m) === vendor) : models;
const options = current && !offered.includes(current) ? [current, ...offered] : offered;
2026-10-14 19:35:34 -07:00
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]!);
2026-10-14 19:35:34 -07:00
select.onchange = () => {
modelChoices.set(key, select.value);
};
return select;
}
function composeForm(
src: ChatSource,
key: string,
sessionId: string | undefined,
sessionState: string,
currentModel: string,
vendor: string | undefined,
showPending: (text: string, imgs: PendingImage[]) => () => void,
): HTMLElement {
2026-10-15 21:56:59 -07:00
const text = h("textarea", { placeholder: src.placeholder, rows: 3 });
2026-10-16 08:42:48 -07:00
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);
2026-10-14 20:07:26 -07:00
const form = h("form", { class: "compose" });
const tray = imageTray(form, text, draftKey, (n) => setComposerImages(draftKey, n));
2026-10-16 08:42:48 -07:00
form.append(lostImagesNote(draftKey) ?? "", 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-16 08:42:48 -07:00
if (!value.trim() && !imageDrafts.has(draftKey)) {
return;
}
2026-10-14 20:07:26 -07:00
const imgs = tray.take();
2026-10-14 19:35:34 -07:00
text.value = "";
2026-10-16 08:42:48 -07:00
const sent = startSend(draftKey, value, imgs.length);
const cancel = showPending(value, imgs);
try {
2026-10-14 20:07:26 -07:00
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);
2026-10-16 08:42:48 -07:00
endSend(sent, true);
form.querySelector(".compose-note")?.remove();
} catch (err) {
cancel();
2026-10-16 08:42:48 -07:00
endSend(sent, false);
const live = composerInputs.get(draftKey) ?? text;
live.value = composerText(draftKey);
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();
}
};
}
export 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 parentOf(p: Record<string, unknown>): string {
return typeof p["parent_tool_use_id"] === "string" ? p["parent_tool_use_id"] : "";
}
function subagentTag(parent: string, ctx: Ctx): HTMLElement | null {
if (!parent) {
return null;
}
2026-10-16 11:26:15 -07:00
if (ctx.subagents.has(parent)) {
return null;
}
return h("span", { class: "subagent-tag" }, "↳ subagent");
}
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", {}, 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: string): string {
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: Record<string, unknown>): boolean {
return p["name"] === "Agent" || p["name"] === "Task";
}
function renderSubagent(p: Record<string, unknown>, ctx: Ctx): HTMLElement {
const input = (typeof p["input"] === "object" && p["input"] !== null ? p["input"] : {}) as Record<string, unknown>;
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…");
2026-10-16 11:26:15 -07:00
const body = h("div", { class: "subagent-body" });
const tag = subagentTag(parentOf(p), ctx);
ctx.tools.set(id, result);
2026-10-16 11:26:15 -07:00
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 }),
2026-10-16 11:26:15 -07:00
body,
result,
);
}
function claudeTaskStatus(p: Record<string, unknown>, ctx: Ctx): void {
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;
}
}
2026-10-15 20:07:36 -07:00
function markReview(button: HTMLElement, r: Review): void {
const n = findingCount(r);
button.classList.toggle("attention", n > 0);
2026-10-15 20:07:36 -07:00
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) {
2026-10-16 10:44:27 -07:00
replace(button, "review", h("span", { class: "review-mark clean", title: Object.keys(r.finding_states).length > 0 ? "every finding addressed" : "no findings" }, "✓"));
2026-10-15 20:07:36 -07:00
return;
}
2026-10-16 10:44:27 -07:00
replace(button, "review", h("span", { class: "badge", title: `${n} open findings` }, n));
}
function snapRow(snap: Snapshot, ctx: Ctx): HTMLElement {
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");
2026-10-15 00:08:32 -07:00
};
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);
2026-10-15 00:08:32 -07:00
}
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 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") {
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", hidden: p["continuation"] === true },
h("summary", {}, h("span", { class: "tool-name" }, "byre"), " ", h("span", { class: "tool-brief" }, brief), " ", when),
markdown(text, "text md", 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", md) : null,
2026-10-14 20:07:26 -07:00
);
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);
}
2026-10-14 21:16:05 -07:00
target.className = "tool-result";
replace(target, body);
return null;
}
case "turn.ended":
2026-10-16 23:24:29 -07:00
for (const result of ctx.tools.values()) {
if (result.classList.contains("pending")) {
result.className = "tool-result unfinished";
replace(result, "no result");
}
}
2026-10-14 19:35:34 -07:00
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: ", 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.created": {
2026-10-16 10:36:38 -07:00
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,
);
}
2026-10-16 10:36:38 -07:00
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;
}
2026-10-16 10:36:38 -07:00
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 as unknown as Review;
2026-10-15 20:07:36 -07:00
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 "task.merge_after_review": {
const taskId = String(p["id"]);
const cancel = h("button", { type: "button", class: "merge-after-review-cancel", hidden: true, title: "Withdraw this request; nothing is merged", data: { taskId } }, "Cancel");
cancel.onclick = async () => {
cancel.disabled = true;
try {
await del("/api/tasks/{taskId}/merge-after-review", { path: { taskId } });
} catch (err) {
void showAlert(errorText(err));
} finally {
cancel.disabled = false;
}
};
return h("div", { class: "note" }, `${ev.actor} asked to merge once the review of snapshot #${String(p["snapshot_n"])} finishes with no findings `, when, cancel);
}
case "task.merge_after_review_cancelled":
return h("div", { class: "note" }, `${ev.actor} cancelled the merge after review `, when);
case "task.merge_after_review_dropped":
return h("div", { class: "note" }, `didn't merge after the 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);
}
2026-10-14 19:35:34 -07:00
case "task.merged":
2026-10-16 10:36:38 -07:00
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,
);
2026-10-14 19:35:34 -07:00
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);
2026-10-14 19:35:34 -07:00
case "task.discarded":
return h("div", { class: "note" }, `${ev.actor} discarded the task `, when);
}
return null;
}
function syncMergeAfterReview(root: ParentNode): void {
const buttons = [...root.querySelectorAll<HTMLButtonElement>(".merge-after-review-cancel")];
const last = buttons.at(-1);
for (const b of buttons) {
b.hidden = b !== last || findTask(b.dataset["taskId"] ?? "")?.merge_after_review !== true;
}
}
2026-10-16 10:36:38 -07:00
function taskLink(id: string): Node {
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: MouseEvent) => {
e.preventDefault();
focusTask(task.id);
},
},
task.title,
);
}
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" }));
}),
);
}
export async function confirmMerge(taskId: string): Promise<void> {
const task = findTask(taskId);
const sessionState = currentSession(taskId)?.state;
2026-10-16 08:29:51 -07:00
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;
}
2026-10-16 10:36:38 -07:00
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";
2026-10-16 10:36:38 -07:00
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) {
2026-10-16 08:29:51 -07:00
void showAlert(`Nothing to merge: no commits or uncommitted files differ from ${target}`);
return;
}
coAuthors = people;
} catch (err) {
void showAlert(errorText(err));
return;
}
2026-10-16 09:49:58 -07:00
const title = mergeDrafts.get(taskId) ?? task.merge_title;
mergeDrafts.delete(taskId);
let input: HTMLInputElement | undefined;
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<HTMLButtonElement>(".modal-actions button:last-child")?.click();
}
};
input = field;
replace(row, field);
field.focus();
field.select();
};
const warnings = h("ul", { class: "merge-warnings" });
2026-10-16 10:36:38 -07:00
const into = h("p", { class: "merge-into" }, `Merge into ${target} as:`);
2026-10-16 09:49:58 -07:00
const co = coAuthorsSummary(coAuthors);
2026-10-16 10:36:38 -07:00
const toMain: Node[] = co ? [into, row, co] : [into, row];
let toParent = false;
const question: Node[] = [];
if (parent?.status === "active") {
toParent = true;
const option = (value: boolean, label: string, hint: string) => {
const radio = h("input", { type: "radio", name: "merge-target", checked: value === toParent });
radio.onchange = () => {
toParent = value;
for (const n of toMain) {
(n as HTMLElement).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 as HTMLElement).hidden = true;
}
}
2026-10-16 10:36:38 -07:00
question.push(...toMain.slice(0, 2), warnings, ...toMain.slice(2));
2026-10-16 09:49:58 -07:00
const leave = (go: () => void) => {
mergeDrafts.set(taskId, input?.value ?? title);
warnings.closest("dialog")?.close("cancel");
go();
};
const watch = watchMergeWarnings(taskId, warnings, leave);
await watch.ready;
const anchor = document.querySelector(".merge-button");
const merge = async (afterReview: boolean) => {
const body = toParent ? { target: "parent" as const } : { merge_title: input?.value ?? title };
try {
await post("/api/tasks/{taskId}/merge", { path: { taskId }, body: afterReview ? { ...body, after_review: true } : body });
} catch (err) {
await showAlert(errorText(err));
}
};
2026-10-16 09:49:58 -07:00
try {
if (!watch.reviewRunning()) {
if (await showConfirm(question, "Merge", false, anchor)) {
await merge(false);
}
return;
}
const choice = await showChoice(
question,
[
{ label: "Merge now", value: "now" },
{ label: "After review", value: "after", class: "primary", title: "Merge once the running review finishes with no findings" },
],
anchor,
);
if (choice === "now" || choice === "after") {
await merge(choice === "after");
}
2026-10-16 09:49:58 -07:00
} finally {
watch.dispose();
}
}
const mergeDrafts = new Map<string, string>();
function watchMergeWarnings(taskId: string, list: HTMLElement, leave: (go: () => void) => void): { ready: Promise<void>; reviewRunning: () => boolean; dispose: () => void } {
2026-10-16 09:49:58 -07:00
let reviewItems: HTMLElement[] = [];
let running = false;
2026-10-16 09:49:58 -07:00
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: HTMLElement[];
let isRunning = false;
2026-10-16 09:49:58 -07:00
try {
({ items, running: isRunning } = await reviewWarnings(taskId, leave));
2026-10-16 09:49:58 -07:00
} catch (err) {
items = [mergeWarning("Review status unavailable", undefined, errorText(err))];
}
if (started === target) {
reviewItems = items;
running = isRunning;
2026-10-16 09:49:58 -07:00
render();
}
};
const offs = [
on("event", (detail) => {
const ev = detail as ByreEvent;
if (ev.task_id === taskId && (ev.kind === "snapshot.created" || ev.kind.startsWith("review."))) {
void load();
}
}),
on("inbox", render),
];
return {
ready: load(),
reviewRunning: () => running,
2026-10-16 09:49:58 -07:00
dispose: () => {
target++;
for (const off of offs) {
off();
}
},
};
}
async function reviewWarnings(taskId: string, leave: (go: () => void) => void): Promise<{ items: HTMLElement[]; running: boolean }> {
2026-10-16 09:49:58 -07:00
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 { items: [mergeWarning("These changes haven't been reviewed")], running: false };
2026-10-16 09:49:58 -07:00
}
const items: HTMLElement[] = [];
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));
2026-10-16 10:44:27 -07:00
} else if (unignoredFindings(r).length > 0) {
items.push(mergeWarning(`Review of the latest snapshot found ${severityCounts(unignoredFindings(r))}`, open));
2026-10-16 09:49:58 -07:00
}
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, running: r?.state === "running" };
2026-10-16 09:49:58 -07:00
}
function mergeWarning(text: string, open?: () => void, detail?: string): HTMLElement {
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: Participant[]): HTMLElement | null {
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: Participant[]): string {
const lines: string[] = [];
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: string | Node[], action: string, danger: boolean, anchor: Element | null, act: () => Promise<void>) {
if (!(await showConfirm(question, action, danger, anchor))) {
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);
}