Files
byre/web/src/task.ts
T

1582 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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);
}