1582 lines
54 KiB
TypeScript
1582 lines
54 KiB
TypeScript
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";
|
||
import { markdown } from "./markdown.js";
|
||
import { previewURL } from "./preview.js";
|
||
import { findingCount, reviewCounts, severityCounts, unignoredFindings } from "./reviews.js";
|
||
import { composerText, endSend, flushDrafts, forgetLostImages, lostImageCount, setComposerImages, setComposerText, startSend } from "./drafts.js";
|
||
import { 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";
|
||
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;
|
||
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 = {
|
||
el: HTMLElement;
|
||
type: string;
|
||
description: string;
|
||
status: HTMLElement;
|
||
body: HTMLElement;
|
||
run: Run | undefined;
|
||
};
|
||
|
||
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;
|
||
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,
|
||
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;
|
||
const intoParent = task.merging_into_parent;
|
||
const merge = h(
|
||
"button",
|
||
{
|
||
class: "merge-button",
|
||
disabled: busy || held || intoParent || unmerged?.taskId !== task.id || !unmerged.has,
|
||
title: intoParent
|
||
? "Waiting for the parent task's agent to merge this subtask"
|
||
: held
|
||
? "Waiting for the merge in progress in this project to finish"
|
||
: task.merge_after_review
|
||
? "Merges when the running review finishes with no findings; open to merge now instead (⌘S)"
|
||
: task.parent_task_id !== undefined
|
||
? `Merge this subtask into its parent task's branch, or into ${target} (⌘S)`
|
||
: `The agent merges ${target} in, then byre squashes into one commit on ${target} and pushes it to origin; held while another task in the project is merging (⌘S)`,
|
||
},
|
||
intoParent ? "Merging into parent" : held ? "Merge held" : 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,
|
||
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 {
|
||
transcript.insertBefore(el, scanner);
|
||
}
|
||
};
|
||
const syncRunning = () => {
|
||
setRunning(src.session()?.state === "running" && ctx.open);
|
||
transcript.classList.toggle("subagents", (src.session()?.subagents ?? 0) > 0);
|
||
};
|
||
const release = () => {
|
||
const buffered = ctx.held;
|
||
if (!buffered) {
|
||
return;
|
||
}
|
||
ctx.held = undefined;
|
||
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;
|
||
ctx.open = false;
|
||
ctx.quiet = false;
|
||
syncRunning();
|
||
};
|
||
|
||
const renderHead = () => {
|
||
syncRunning();
|
||
if (!src.key()) {
|
||
replace(head);
|
||
return;
|
||
}
|
||
replace(head, ...headImpl.render((id) => context.get(id)));
|
||
};
|
||
|
||
let composerModel = "";
|
||
let focusStart = false;
|
||
const renderComposer = () => {
|
||
const key = src.key();
|
||
const old = composer.querySelector("textarea");
|
||
if (old) {
|
||
releaseInput(old);
|
||
}
|
||
if (!key) {
|
||
replace(composer);
|
||
return;
|
||
}
|
||
const sess = src.session();
|
||
const live = sess && sess.state !== "completed" ? sess : undefined;
|
||
composerModel = live ? live.model || context.get(live.id)?.model || "" : "";
|
||
const form = composeForm(src, key, live?.id, live?.state ?? "idle", composerModel, live?.vendor, showPending);
|
||
const caret = old !== null && document.activeElement === old ? [old.selectionStart, old.selectionEnd] : undefined;
|
||
replace(composer, form);
|
||
const text = form.querySelector("textarea")!;
|
||
if (caret) {
|
||
text.focus();
|
||
text.setSelectionRange(caret[0]!, caret[1]!);
|
||
} else if (focusStart && !sess) {
|
||
text.focus();
|
||
}
|
||
focusStart = false;
|
||
};
|
||
|
||
let pending: { 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) => {
|
||
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));
|
||
};
|
||
|
||
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));
|
||
return run;
|
||
};
|
||
|
||
let stream: { el: HTMLElement; text: string; timer: number } | undefined;
|
||
const dropStream = () => {
|
||
if (!stream) {
|
||
return;
|
||
}
|
||
clearTimeout(stream.timer);
|
||
stream.el.remove();
|
||
stream = undefined;
|
||
};
|
||
const appendDelta = (ev: ByreEvent) => {
|
||
if (ctx.handoff || (ev.agent_session_id && ctx.reviewers.has(ev.agent_session_id))) {
|
||
return;
|
||
}
|
||
if (!stream) {
|
||
stream = { el: h("div", { class: "msg agent", hidden: true }), text: "", timer: 0 };
|
||
ctx.run = undefined;
|
||
put(stream.el, true);
|
||
}
|
||
stream.text += String(ev.payload["text"] ?? "");
|
||
if (stream.timer) {
|
||
return;
|
||
}
|
||
stream.timer = setTimeout(() => {
|
||
if (!stream) {
|
||
return;
|
||
}
|
||
stream.timer = 0;
|
||
stream.el.hidden = ctx.held !== undefined || quietReply.startsWith(stream.text.trim());
|
||
replace(stream.el, markdown(stream.text, "text md", { taskId: ctx.taskId }));
|
||
fillTodoLinks(stream.el, models[0]!);
|
||
pin();
|
||
}, 100);
|
||
};
|
||
|
||
const openHandoff = () => {
|
||
const summary = h("summary", {}, "writing a handoff…");
|
||
const body = h("div", { class: "folded-turn" });
|
||
put(h("details", { class: "note" }, summary, body));
|
||
ctx.handoff = { summary, body };
|
||
};
|
||
const closeHandoff = (end: 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");
|
||
}
|
||
return handoff.summary;
|
||
};
|
||
|
||
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 (ev.kind === "session.subagents") {
|
||
if (p["subagents"] === 0) {
|
||
for (const sub of ctx.subagents.values()) {
|
||
if (sub.el.classList.contains("active")) {
|
||
endSubagent(sub, "error", "ended with its process");
|
||
}
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
if (!sink && ((ev.kind === "agent.message" && !parent) || ev.kind === "turn.ended" || ev.kind === "user.message")) {
|
||
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;
|
||
}
|
||
if (ev.kind === "snapshot.created") {
|
||
ctx.run = undefined;
|
||
put(snapRow(p as unknown as Snapshot, ctx));
|
||
continue;
|
||
}
|
||
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) {
|
||
markReview(button, r);
|
||
}
|
||
}
|
||
const sub = ctx.subagents.get(parent);
|
||
if (ev.kind === "agent.tool_call" && isSubagentCall(p)) {
|
||
const el = renderSubagent(p, ctx);
|
||
if (sub) {
|
||
sub.run = undefined;
|
||
sub.body.append(el);
|
||
continue;
|
||
}
|
||
ctx.run = undefined;
|
||
put(el, 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;
|
||
}
|
||
if (sub) {
|
||
sub.run = undefined;
|
||
sub.body.append(el);
|
||
continue;
|
||
}
|
||
ctx.run = undefined;
|
||
if (ev.kind === "agent.message" && !parent) {
|
||
ctx.drawn = undefined;
|
||
}
|
||
if (ev.kind === "turn.ended") {
|
||
ctx.open = false;
|
||
}
|
||
if (ev.kind === "turn.ended" && p["handoff"] === true && ctx.handoff) {
|
||
closeHandoff(el);
|
||
continue;
|
||
}
|
||
if (ev.kind === "turn.ended" || (ev.kind === "user.message" && p["mid_turn"] !== true)) {
|
||
closeHandoff(undefined);
|
||
}
|
||
if (ev.kind === "user.message" && p["handoff"] === true) {
|
||
openHandoff();
|
||
}
|
||
if (ev.kind === "turn.ended" && ctx.quiet && ctx.drawn) {
|
||
fold();
|
||
continue;
|
||
}
|
||
if (ev.kind === "turn.ended" || ev.kind === "user.message") {
|
||
ctx.drawn = undefined;
|
||
}
|
||
if (ev.kind === "user.message" && ev.actor === "byre" && ev.owner && p["mid_turn"] !== true && p["handoff"] !== true) {
|
||
ctx.held = document.createDocumentFragment();
|
||
ctx.open = false;
|
||
ctx.drawn = [];
|
||
continue;
|
||
}
|
||
if (ev.kind === "user.message" && p["mid_turn"] !== true) {
|
||
ctx.open = true;
|
||
}
|
||
if (ev.kind === "turn.ended" && p["summary"] === true && ctx.taskId) {
|
||
el.dataset["seq"] = String(ev.seq);
|
||
sightings.observe(el);
|
||
}
|
||
put(el);
|
||
}
|
||
return contextChanged;
|
||
};
|
||
|
||
const appendEvents = (events: ByreEvent[]) => {
|
||
headImpl.onEvents?.(events);
|
||
const contextChanged = renderEvents(events);
|
||
syncRunning();
|
||
if (contextChanged) {
|
||
renderHead();
|
||
const sess = src.session();
|
||
if (sess && !sess.model && (context.get(sess.id)?.model ?? "") !== composerModel) {
|
||
renderComposer();
|
||
}
|
||
}
|
||
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;
|
||
dropStream();
|
||
pending = [];
|
||
sightings.disconnect();
|
||
shown.clear();
|
||
ctx = newCtx();
|
||
context = new Map();
|
||
focusStart = true;
|
||
headImpl.reset?.();
|
||
renderHead();
|
||
if (!src.key()) {
|
||
renderComposer();
|
||
replace(transcript, h("div", { class: "empty" }, src.empty), scanner);
|
||
return;
|
||
}
|
||
replace(transcript, earlier, scanner);
|
||
if (src.loading()) {
|
||
transcript.prepend(loading);
|
||
}
|
||
appendEvents(src.events());
|
||
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),
|
||
];
|
||
|
||
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);
|
||
dropStream();
|
||
const input = composer.querySelector("textarea");
|
||
if (input) {
|
||
releaseInput(input);
|
||
}
|
||
flushDrafts();
|
||
};
|
||
}
|
||
|
||
const composerInputs = new Map<string, HTMLTextAreaElement>();
|
||
|
||
function releaseInput(input: HTMLTextAreaElement): void {
|
||
for (const [key, el] of composerInputs) {
|
||
if (el === input) {
|
||
composerInputs.delete(key);
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
const select = h("select", { title: "Model for the next turn" }, ...options.map((m) => h("option", { value: m }, m)));
|
||
select.value = modelChoices.get(key) ?? (current || options[0]!);
|
||
select.onchange = () => {
|
||
modelChoices.set(key, select.value);
|
||
};
|
||
return select;
|
||
}
|
||
|
||
function composeForm(
|
||
src: ChatSource,
|
||
key: string,
|
||
sessionId: string | undefined,
|
||
sessionState: string,
|
||
currentModel: string,
|
||
vendor: string | undefined,
|
||
showPending: (text: string, imgs: PendingImage[]) => () => void,
|
||
): HTMLElement {
|
||
const text = h("textarea", { placeholder: src.placeholder, rows: 3 });
|
||
const draftKey = `message:${key}`;
|
||
keepDraft(text, draftKey);
|
||
const send = h("button", { type: "submit", disabled: !src.canSend(sessionState) }, "Send");
|
||
const interrupt = h("button", { type: "button", class: "danger", disabled: sessionState !== "running" }, "Interrupt");
|
||
interrupt.onclick = async () => {
|
||
if (!sessionId) {
|
||
return;
|
||
}
|
||
try {
|
||
await post("/api/sessions/{sessionId}/interrupt", { path: { sessionId } });
|
||
} catch (err) {
|
||
void showAlert(errorText(err));
|
||
}
|
||
};
|
||
|
||
const modelKey = `message:${sessionId ?? key}`;
|
||
const model = modelSelect(modelKey, currentModel, vendor);
|
||
const form = h("form", { class: "compose" });
|
||
const tray = imageTray(form, text, draftKey, (n) => setComposerImages(draftKey, n));
|
||
form.append(lostImagesNote(draftKey) ?? "", tray.el, text, h("div", { class: "compose-actions" }, model, interrupt, send));
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const value = text.value;
|
||
if (!value.trim() && !imageDrafts.has(draftKey)) {
|
||
return;
|
||
}
|
||
const imgs = tray.take();
|
||
text.value = "";
|
||
const sent = startSend(draftKey, value, imgs.length);
|
||
const cancel = showPending(value, imgs);
|
||
try {
|
||
const images = await uploadImages(imgs);
|
||
await src.send({ key, sessionId, text: value, images, model: model.value !== currentModel ? model.value : undefined, firstModel: model.value });
|
||
modelChoices.delete(modelKey);
|
||
endSend(sent, true);
|
||
form.querySelector(".compose-note")?.remove();
|
||
} catch (err) {
|
||
cancel();
|
||
endSend(sent, false);
|
||
const live = composerInputs.get(draftKey) ?? text;
|
||
live.value = composerText(draftKey);
|
||
tray.restore(imgs);
|
||
void showAlert(errorText(err));
|
||
}
|
||
};
|
||
submitOnEnter(text, form);
|
||
return form;
|
||
}
|
||
|
||
function submitOnEnter(input: 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})` : ""}` : "";
|
||
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: 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;
|
||
}
|
||
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…");
|
||
const body = h("div", { class: "subagent-body" });
|
||
const tag = subagentTag(parentOf(p), ctx);
|
||
const el = h(
|
||
"details",
|
||
{ class: "tool subagent" },
|
||
h("summary", {}, tag, h("span", { class: "tool-name" }, `${p["name"]} ${type}`), " ", h("span", { class: "tool-brief" }, description), " ", status),
|
||
markdown(String(input["prompt"] ?? ""), "text md subagent-prompt", { taskId: ctx.taskId }),
|
||
body,
|
||
result,
|
||
);
|
||
ctx.tools.set(id, result);
|
||
ctx.subagents.set(id, { el, type, description, status, body, run: undefined });
|
||
return el;
|
||
}
|
||
|
||
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":
|
||
sub.el.classList.add("active");
|
||
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":
|
||
endSubagent(sub, p["status"] === "completed" ? "done" : "error", String(p["status"]));
|
||
return;
|
||
}
|
||
}
|
||
|
||
function endSubagent(sub: Subagent, kind: string, status: string): void {
|
||
sub.el.classList.remove("active");
|
||
sub.status.className = `subagent-status ${kind}`;
|
||
replace(sub.status, status);
|
||
for (const result of sub.body.querySelectorAll(".tool-result.pending")) {
|
||
result.className = "tool-result unfinished";
|
||
replace(result as HTMLElement, "no result");
|
||
}
|
||
}
|
||
|
||
function markReview(button: HTMLElement, r: Review): void {
|
||
const n = findingCount(r);
|
||
button.classList.toggle("attention", n > 0);
|
||
if (r.state === "running") {
|
||
replace(button, "review", h("span", { class: "review-mark spinner", title: "review in progress" }));
|
||
return;
|
||
}
|
||
if (r.state === "failed") {
|
||
replace(button, "review");
|
||
return;
|
||
}
|
||
if (r.state === "skipped") {
|
||
replace(button, "review", h("span", { class: "review-mark skipped", title: "skipped: no changes" }, "●"));
|
||
return;
|
||
}
|
||
if (n === 0) {
|
||
replace(button, "review", h("span", { class: "review-mark clean", title: Object.keys(r.finding_states).length > 0 ? "every finding addressed" : "no findings" }, "✓"));
|
||
return;
|
||
}
|
||
replace(button, "review", h("span", { class: "badge", title: `${n} open findings` }, n));
|
||
}
|
||
|
||
function snapRow(snap: 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");
|
||
};
|
||
const fork = h("button", { type: "button", title: "Start a new task from this snapshot, its agent continuing the conversation as it stood here" }, "fork");
|
||
fork.onclick = async () => {
|
||
if (!taskId) {
|
||
return;
|
||
}
|
||
fork.disabled = true;
|
||
try {
|
||
const task = await post("/api/tasks/{taskId}/snapshots/{n}/fork", { path: { taskId, n: snap.n } });
|
||
focusTask(task.id);
|
||
} catch (err) {
|
||
fork.disabled = false;
|
||
void showAlert(errorText(err));
|
||
}
|
||
};
|
||
const review = h("button", { type: "button", title: `Show the review of snapshot ${snap.n} in the Reviews tab` }, "review");
|
||
review.onclick = () => showReview(snap.n);
|
||
ctx.reviewButtons.set(snap.n, review);
|
||
const time = new Date(snap.created_at).toLocaleTimeString([], { hour12: false });
|
||
return h("div", { class: "snap-row", title: `${snap.trigger.replace("_", " ")} · ${time}` }, `snapshot #${snap.n}`, changes, fork, review);
|
||
}
|
||
|
||
function runSummary(calls: 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") {
|
||
const text = String(p["text"]);
|
||
const first = text.split("\n")[0] ?? "";
|
||
const brief = first.length > 120 ? `${first.slice(0, 120)}…` : first;
|
||
return h(
|
||
"details",
|
||
{ class: "tool byre-prompt", hidden: p["continuation"] === true },
|
||
h("summary", {}, h("span", { class: "tool-name" }, "byre"), " ", h("span", { class: "tool-brief" }, brief), " ", when),
|
||
markdown(text, "text md", md),
|
||
);
|
||
}
|
||
return h(
|
||
"div",
|
||
{ class: "msg user" },
|
||
h("div", { class: "who" }, ev.actor, " ", when),
|
||
messageImages(p["images"]),
|
||
String(p["text"] ?? "").trim() ? markdown(String(p["text"]), "text md", md) : null,
|
||
);
|
||
|
||
case "agent.message":
|
||
if (parentOf(p)) {
|
||
return h("div", { class: "msg agent subagent" }, h("div", { class: "who" }, subagentTag(parentOf(p), ctx), " ", when), markdown(String(p["text"]), "text md", md));
|
||
}
|
||
if (String(p["text"]).trim().split("\n").pop()?.trim() === quietReply) {
|
||
ctx.quiet = true;
|
||
return null;
|
||
}
|
||
ctx.quiet = false;
|
||
return h("div", { class: "msg agent" }, markdown(String(p["text"]), "text md", md));
|
||
|
||
case "agent.thinking":
|
||
return h("details", { class: "thinking" }, h("summary", {}, subagentTag(parentOf(p), ctx), "thinking"), markdown(String(p["text"]), "text md", md));
|
||
|
||
case "agent.tool_result": {
|
||
const target = ctx.tools.get(String(p["tool_use_id"]));
|
||
const body = copyable(h("pre", {}, resultText(p["content"])));
|
||
if (!target) {
|
||
return h("div", { class: "tool-result orphan" }, body);
|
||
}
|
||
target.className = "tool-result";
|
||
replace(target, body);
|
||
return null;
|
||
}
|
||
|
||
case "turn.ended":
|
||
for (const result of ctx.tools.values()) {
|
||
if (result.classList.contains("pending") && !result.closest(".tool.subagent.active")) {
|
||
result.className = "tool-result unfinished";
|
||
replace(result, "no result");
|
||
}
|
||
}
|
||
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"] 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": {
|
||
if (typeof p["forked_from"] === "string") {
|
||
return h(
|
||
"div",
|
||
{ class: "note" },
|
||
`${ev.actor} forked this task from `,
|
||
taskLink(p["forked_from"]),
|
||
` at snapshot #${String(p["forked_at"])}; its agent continues that task's conversation as it stood there `,
|
||
when,
|
||
);
|
||
}
|
||
if (typeof p["parent_task_id"] === "string") {
|
||
return h("div", { class: "note" }, "subtask of ", taskLink(p["parent_task_id"]), ", created by its agent ", when);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
case "task.parent_merge_started":
|
||
return h("div", { class: "note" }, `${ev.actor} asked `, taskLink(String(p["parent_task_id"])), "'s agent to merge this subtask into its branch ", when);
|
||
|
||
case "task.parent_merge_stopped":
|
||
return h("div", { class: "note error" }, "the merge into the parent task didn't start ", when);
|
||
|
||
case "task.merge_title":
|
||
return h("div", { class: "note" }, `${ev.actor} set the merge commit title to "${String(p["merge_title"])}" `, when);
|
||
|
||
case "review.finished": {
|
||
const r = p as unknown as Review;
|
||
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);
|
||
}
|
||
|
||
case "task.merged":
|
||
if (typeof p["parent_task_id"] === "string") {
|
||
return h("div", { class: "note" }, `${ev.actor} merged the task into `, taskLink(p["parent_task_id"]), `, now at ${String(p["commit"]).slice(0, 7)} `, when);
|
||
}
|
||
return h(
|
||
"div",
|
||
{ class: "note" },
|
||
`${ev.actor} merged the task${p["commit"] ? ` as ${String(p["commit"]).slice(0, 7)}` : ""}${p["pushed"] ? ` and pushed it to ${String(p["pushed"])}` : ""} `,
|
||
when,
|
||
);
|
||
|
||
case "task.summary":
|
||
return h("div", { class: "note" }, `${ev.actor} set the task summary `, when);
|
||
|
||
case "task.discard_started":
|
||
return h("div", { class: "note" }, `${ev.actor} asked to discard the task; the agent writes the task summary first `, when);
|
||
|
||
case "task.discarded":
|
||
return h("div", { class: "note" }, `${ev.actor} discarded the task `, when);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function 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;
|
||
}
|
||
}
|
||
|
||
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,
|
||
);
|
||
}
|
||
|
||
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;
|
||
if (!task || task.status !== "active") {
|
||
return;
|
||
}
|
||
if (sessionState === "running") {
|
||
void showAlert("Agent is mid-turn; merge when it's idle");
|
||
return;
|
||
}
|
||
if (task.merge_queued_at !== undefined) {
|
||
void showAlert("Merge already held behind another merge in this project");
|
||
return;
|
||
}
|
||
if (task.merging_into_parent) {
|
||
void showAlert("Already waiting for the parent task's agent to merge this subtask");
|
||
return;
|
||
}
|
||
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
|
||
const parent = task.parent_task_id !== undefined ? findTask(task.parent_task_id) : undefined;
|
||
let coAuthors;
|
||
try {
|
||
const [unmerged, people] = await Promise.all([
|
||
get("/api/tasks/{taskId}/unmerged", { path: { taskId } }),
|
||
get("/api/tasks/{taskId}/co-authors", { path: { taskId } }),
|
||
]);
|
||
if (unmerged.commits === 0 && unmerged.files === 0) {
|
||
void showAlert(`Nothing to merge: no commits or uncommitted files differ from ${target}`);
|
||
return;
|
||
}
|
||
coAuthors = people;
|
||
} catch (err) {
|
||
void showAlert(errorText(err));
|
||
return;
|
||
}
|
||
const title = mergeDrafts.get(taskId) ?? task.merge_title;
|
||
mergeDrafts.delete(taskId);
|
||
let input: 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" });
|
||
const into = h("p", { class: "merge-into" }, `Merge into ${target} as:`);
|
||
const co = coAuthorsSummary(coAuthors);
|
||
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;
|
||
}
|
||
}
|
||
question.push(...toMain.slice(0, 2), warnings, ...toMain.slice(2));
|
||
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));
|
||
}
|
||
};
|
||
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");
|
||
}
|
||
} 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 } {
|
||
let reviewItems: HTMLElement[] = [];
|
||
let running = false;
|
||
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;
|
||
try {
|
||
({ items, running: isRunning } = await reviewWarnings(taskId, leave));
|
||
} catch (err) {
|
||
items = [mergeWarning("Review status unavailable", undefined, errorText(err))];
|
||
}
|
||
if (started === target) {
|
||
reviewItems = items;
|
||
running = isRunning;
|
||
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,
|
||
dispose: () => {
|
||
target++;
|
||
for (const off of offs) {
|
||
off();
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
async function reviewWarnings(taskId: string, leave: (go: () => void) => void): Promise<{ items: HTMLElement[]; running: boolean }> {
|
||
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 };
|
||
}
|
||
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));
|
||
} else if (unignoredFindings(r).length > 0) {
|
||
items.push(mergeWarning(`Review of the latest snapshot found ${severityCounts(unignoredFindings(r))}`, open));
|
||
}
|
||
const changes = await get("/api/tasks/{taskId}/changes", { path: { taskId }, query: { since: latest.commit_sha } });
|
||
if (changes.files.length > 0) {
|
||
items.push(mergeWarning("Changes since the last snapshot haven't been reviewed"));
|
||
}
|
||
return { items, running: r?.state === "running" };
|
||
}
|
||
|
||
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;
|
||
}
|
||
try {
|
||
await act();
|
||
} catch (err) {
|
||
await showAlert(errorText(err));
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|