988 lines
39 KiB
JavaScript
988 lines
39 KiB
JavaScript
import { get, post } from "./api.js";
|
||
import { ago, copyable, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
|
||
import { markdown } from "./markdown.js";
|
||
import { previewURL } from "./preview.js";
|
||
import { findingCount, reviewCounts } from "./reviews.js";
|
||
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, showReview, state } from "./state.js";
|
||
import { taskStyle } from "./taskcolor.js";
|
||
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";
|
||
const taskSource = {
|
||
key: () => state.selectedTaskId,
|
||
events: () => state.transcript,
|
||
session: () => (state.selectedTaskId ? currentSession(state.selectedTaskId) : undefined),
|
||
root: () => (state.selectedTaskId ? (findTask(state.selectedTaskId)?.worktree_path ?? "") : ""),
|
||
taskId: () => state.selectedTaskId,
|
||
resetTopics: ["selection"],
|
||
refreshTopics: ["tasks", "sessions"],
|
||
transcriptTopic: "transcript",
|
||
streamTopic: "stream",
|
||
empty: "No task selected. Ask your lead, or pick one with ⌘K.",
|
||
canSend: () => true,
|
||
placeholder: (s) => s === "running" ? "Message the agent; it reads this after its next tool call, or when its turn ends" : "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) {
|
||
return mountChat(root, taskSource, taskHead);
|
||
}
|
||
function taskHead(rerender) {
|
||
let unmerged;
|
||
let timer;
|
||
const load = async () => {
|
||
const taskId = state.selectedTaskId;
|
||
if (!taskId) {
|
||
return;
|
||
}
|
||
try {
|
||
const u = await get("/api/tasks/{taskId}/unmerged", { path: { taskId } });
|
||
if (state.selectedTaskId !== taskId) {
|
||
return;
|
||
}
|
||
const has = u.commits > 0 || u.files > 0;
|
||
if (unmerged?.taskId === taskId && unmerged.has === has) {
|
||
return;
|
||
}
|
||
unmerged = { taskId, has };
|
||
rerender();
|
||
}
|
||
catch (err) {
|
||
console.error("load unmerged changes", err);
|
||
}
|
||
};
|
||
return {
|
||
reset: () => {
|
||
clearTimeout(timer);
|
||
void load();
|
||
},
|
||
dispose: () => clearTimeout(timer),
|
||
onEvents: (events) => {
|
||
if (events.some((ev) => ev.kind === "agent.tool_result" || ev.kind === "turn.ended")) {
|
||
clearTimeout(timer);
|
||
timer = setTimeout(() => void load(), 1500);
|
||
}
|
||
},
|
||
render: (usage) => {
|
||
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
|
||
if (!task) {
|
||
return [];
|
||
}
|
||
const sess = currentSession(task.id);
|
||
if (task.status !== "active") {
|
||
return [h("div", { class: "head-actions" }, task.status)];
|
||
}
|
||
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
|
||
const busy = sess?.state === "running";
|
||
const held = task.merge_queued_at !== undefined;
|
||
const merge = h("button", {
|
||
class: "merge-button",
|
||
disabled: busy || held || unmerged?.taskId !== task.id || !unmerged.has,
|
||
title: held
|
||
? "Waiting for the merge in progress in this project to finish"
|
||
: `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)`,
|
||
}, held ? "Merge held" : "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: "Delete 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, src, makeHead) {
|
||
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 newCtx = () => ({
|
||
tools: new Map(),
|
||
subagents: new Map(),
|
||
reviewers: new Set(),
|
||
reviewButtons: new Map(),
|
||
run: undefined,
|
||
held: undefined,
|
||
quiet: false,
|
||
handoff: undefined,
|
||
root: src.root,
|
||
taskId: src.taskId(),
|
||
});
|
||
let ctx = newCtx();
|
||
let context = new Map();
|
||
const headImpl = makeHead(() => renderHead());
|
||
const spinner = h("div", { class: "spinner", hidden: true });
|
||
const setRunning = (running) => {
|
||
transcript.classList.toggle("running", running);
|
||
if (running === !spinner.hidden) {
|
||
return;
|
||
}
|
||
spinner.hidden = !running;
|
||
pin();
|
||
};
|
||
const putBefore = (el, anchor) => {
|
||
if (anchor?.parentNode === transcript) {
|
||
transcript.insertBefore(el, anchor);
|
||
}
|
||
else if (spinner.parentNode === transcript) {
|
||
transcript.insertBefore(el, spinner);
|
||
}
|
||
else {
|
||
transcript.append(el);
|
||
}
|
||
};
|
||
const syncRunning = () => setRunning(src.session()?.state === "running" && !ctx.held);
|
||
const release = () => {
|
||
const el = ctx.held;
|
||
if (!el) {
|
||
return;
|
||
}
|
||
ctx.held = undefined;
|
||
putBefore(el, stream?.el ?? pending[0]?.el);
|
||
syncRunning();
|
||
};
|
||
const put = (el, keepHeld = false) => {
|
||
if (ctx.handoff) {
|
||
ctx.handoff.body.append(el);
|
||
return;
|
||
}
|
||
if (!keepHeld) {
|
||
release();
|
||
}
|
||
putBefore(el, pending[0]?.el);
|
||
};
|
||
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();
|
||
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 old = composer.querySelector("textarea");
|
||
const caret = old !== null && document.activeElement === old ? [old.selectionStart, old.selectionEnd] : undefined;
|
||
replace(composer, form);
|
||
const text = form.querySelector("textarea");
|
||
if (caret) {
|
||
text.focus();
|
||
text.setSelectionRange(caret[0], caret[1]);
|
||
}
|
||
else if (focusStart && !sess) {
|
||
text.focus();
|
||
}
|
||
focusStart = false;
|
||
};
|
||
let pending = [];
|
||
const showPending = (text, imgs) => {
|
||
const el = h("div", { class: "msg user pending" }, h("div", { class: "who" }, 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, spinner);
|
||
stuck = true;
|
||
pin();
|
||
return () => {
|
||
el.remove();
|
||
pending = pending.filter((p) => p !== entry);
|
||
};
|
||
};
|
||
const confirmPending = (text) => {
|
||
const entry = pending.find((p) => p.text === text);
|
||
if (!entry) {
|
||
return;
|
||
}
|
||
entry.el.remove();
|
||
releaseImages(entry.imgs);
|
||
pending = pending.filter((p) => p !== entry);
|
||
};
|
||
const runHead = (run) => [subagentTag(run.parent, ctx), runSummary(run.calls)];
|
||
const addToRun = (el, call, parent) => {
|
||
const run = ctx.run;
|
||
if (!run || run.parent !== parent) {
|
||
ctx.run = { first: el, group: undefined, calls: call ? [call] : [], parent };
|
||
put(el);
|
||
return;
|
||
}
|
||
if (call) {
|
||
run.calls.push(call);
|
||
}
|
||
if (!run.group) {
|
||
const summary = h("summary");
|
||
const body = h("div", { class: "tool-group-body" });
|
||
const group = h("details", { class: "tool-group" }, summary, body);
|
||
run.first.replaceWith(group);
|
||
body.append(run.first);
|
||
run.group = { el: group, summary, body };
|
||
}
|
||
run.group.body.append(el);
|
||
replace(run.group.summary, ...runHead(run));
|
||
};
|
||
let stream;
|
||
const dropStream = () => {
|
||
if (!stream) {
|
||
return;
|
||
}
|
||
clearTimeout(stream.timer);
|
||
stream.el.remove();
|
||
stream = undefined;
|
||
};
|
||
const appendDelta = (ev) => {
|
||
if (ctx.handoff || (ev.agent_session_id && ctx.reviewers.has(ev.agent_session_id))) {
|
||
return;
|
||
}
|
||
if (!stream) {
|
||
stream = { el: h("div", { class: "msg agent" }), 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 }));
|
||
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) => {
|
||
const handoff = ctx.handoff;
|
||
if (!handoff) {
|
||
return undefined;
|
||
}
|
||
ctx.handoff = undefined;
|
||
if (end) {
|
||
replace(handoff.summary, ...end.childNodes);
|
||
}
|
||
else {
|
||
replace(handoff.summary, "handoff not written");
|
||
}
|
||
return handoff.summary;
|
||
};
|
||
const appendEvents = (events) => {
|
||
let contextChanged = false;
|
||
headImpl.onEvents?.(events);
|
||
for (const ev of events) {
|
||
const p = ev.payload;
|
||
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 === "agent.message" && !parent) || ev.kind === "turn.ended" || ev.kind === "user.message") {
|
||
dropStream();
|
||
}
|
||
if (ev.kind === "user.message" && ev.actor !== "byre") {
|
||
confirmPending(String(p["text"] ?? ""));
|
||
}
|
||
if (ev.kind === "session.context" && ev.agent_session_id) {
|
||
const prev = context.get(ev.agent_session_id);
|
||
const window = Number(p["window"]) || prev?.window || 0;
|
||
context.set(ev.agent_session_id, { used: Number(p["used"]), window, model: String(p["model"] ?? "") });
|
||
contextChanged = true;
|
||
continue;
|
||
}
|
||
if (ev.kind === "snapshot.created") {
|
||
ctx.run = undefined;
|
||
put(snapRow(p, ctx));
|
||
continue;
|
||
}
|
||
if (ev.kind === "review.started" || ev.kind === "review.finished") {
|
||
const r = p;
|
||
const button = ctx.reviewButtons.get(r.snapshot_n);
|
||
if (button) {
|
||
markReview(button, r);
|
||
}
|
||
}
|
||
if (ev.kind === "agent.tool_call" && isSubagentCall(p)) {
|
||
ctx.run = undefined;
|
||
put(renderSubagent(p, ctx));
|
||
continue;
|
||
}
|
||
if (ev.kind === "agent.tool_call") {
|
||
addToRun(renderToolCall(p, ctx), { name: String(p["name"]), target: toolTarget(p["input"], ctx.root()) }, parent);
|
||
continue;
|
||
}
|
||
const el = renderEvent(ev, ctx);
|
||
if (!el) {
|
||
continue;
|
||
}
|
||
if (ev.kind === "agent.thinking") {
|
||
addToRun(el, undefined, parent);
|
||
continue;
|
||
}
|
||
ctx.run = undefined;
|
||
if (ev.kind === "turn.ended" && p["handoff"] === true && ctx.handoff) {
|
||
closeHandoff(el);
|
||
continue;
|
||
}
|
||
if (ev.kind === "turn.ended" || (ev.kind === "user.message" && p["mid_turn"] !== true)) {
|
||
closeHandoff(undefined);
|
||
}
|
||
if (ev.kind === "user.message" && p["handoff"] === true) {
|
||
openHandoff();
|
||
}
|
||
if (ev.kind === "turn.ended" && ctx.quiet && ctx.held) {
|
||
ctx.held = undefined;
|
||
ctx.quiet = false;
|
||
continue;
|
||
}
|
||
if (ev.kind === "user.message" && ev.actor === "byre" && ev.owner && p["mid_turn"] !== true && p["handoff"] !== true) {
|
||
ctx.held = el;
|
||
syncRunning();
|
||
continue;
|
||
}
|
||
put(el);
|
||
}
|
||
if (contextChanged) {
|
||
renderHead();
|
||
const sess = src.session();
|
||
if (sess && !sess.model && (context.get(sess.id)?.model ?? "") !== composerModel) {
|
||
renderComposer();
|
||
}
|
||
}
|
||
pin();
|
||
};
|
||
const renderAll = () => {
|
||
stuck = true;
|
||
dropStream();
|
||
pending = [];
|
||
ctx = newCtx();
|
||
context = new Map();
|
||
focusStart = true;
|
||
headImpl.reset?.();
|
||
renderHead();
|
||
if (!src.key()) {
|
||
renderComposer();
|
||
replace(transcript, h("div", { class: "empty" }, src.empty));
|
||
return;
|
||
}
|
||
replace(transcript, spinner);
|
||
appendEvents(src.events());
|
||
renderComposer();
|
||
};
|
||
const offs = [
|
||
onEach(src.resetTopics, renderAll),
|
||
onEach(src.refreshTopics, () => {
|
||
renderHead();
|
||
renderComposer();
|
||
}),
|
||
onEach([src.transcriptTopic], (fresh) => appendEvents(fresh)),
|
||
onEach([src.streamTopic], (ev) => appendDelta(ev)),
|
||
];
|
||
replace(root, h("div", { class: "transcript-view" }, head, transcript, composer));
|
||
renderAll();
|
||
return () => {
|
||
for (const off of offs) {
|
||
off();
|
||
}
|
||
headImpl.dispose?.();
|
||
resizes.disconnect();
|
||
mutations.disconnect();
|
||
dropStream();
|
||
};
|
||
}
|
||
const composerDrafts = new Map();
|
||
function keepDraft(input, key) {
|
||
input.value = composerDrafts.get(key) ?? "";
|
||
input.addEventListener("input", () => {
|
||
if (input.value) {
|
||
composerDrafts.set(key, input.value);
|
||
}
|
||
else {
|
||
composerDrafts.delete(key);
|
||
}
|
||
});
|
||
return () => composerDrafts.delete(key);
|
||
}
|
||
const imageDrafts = new Map();
|
||
const imageTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||
const maxImageBytes = 5 << 20;
|
||
function imageTray(form, input, key) {
|
||
const el = h("div", { class: "compose-images" });
|
||
const render = () => {
|
||
const imgs = imageDrafts.get(key) ?? [];
|
||
el.hidden = imgs.length === 0;
|
||
replace(el, ...imgs.map((img) => {
|
||
const remove = h("button", { type: "button", class: "remove", title: "Remove image" }, "×");
|
||
remove.onclick = () => {
|
||
URL.revokeObjectURL(img.url);
|
||
const rest = (imageDrafts.get(key) ?? []).filter((i) => i !== img);
|
||
if (rest.length > 0) {
|
||
imageDrafts.set(key, rest);
|
||
}
|
||
else {
|
||
imageDrafts.delete(key);
|
||
}
|
||
render();
|
||
};
|
||
return h("div", { class: "thumb" }, h("img", { src: img.url, alt: "attached image" }), remove);
|
||
}));
|
||
};
|
||
const add = (files) => {
|
||
const added = [];
|
||
for (const f of files) {
|
||
if (!imageTypes.includes(f.type)) {
|
||
void showAlert(`${f.name || "That file"} isn't a PNG, JPEG, GIF or WebP image.`);
|
||
continue;
|
||
}
|
||
if (f.size > maxImageBytes) {
|
||
void showAlert(`${f.name || "That image"} is over ${maxImageBytes >> 20} MiB.`);
|
||
continue;
|
||
}
|
||
added.push({ file: f, url: URL.createObjectURL(f) });
|
||
}
|
||
if (added.length === 0) {
|
||
return false;
|
||
}
|
||
imageDrafts.set(key, [...(imageDrafts.get(key) ?? []), ...added]);
|
||
render();
|
||
return true;
|
||
};
|
||
input.addEventListener("paste", (e) => {
|
||
const files = [...(e.clipboardData?.files ?? [])].filter((f) => f.type.startsWith("image/"));
|
||
if (files.length > 0) {
|
||
e.preventDefault();
|
||
add(files);
|
||
}
|
||
});
|
||
form.addEventListener("dragover", (e) => {
|
||
if (e.dataTransfer?.types.includes("Files")) {
|
||
e.preventDefault();
|
||
form.classList.add("dropping");
|
||
}
|
||
});
|
||
form.addEventListener("dragleave", (e) => {
|
||
if (!form.contains(e.relatedTarget)) {
|
||
form.classList.remove("dropping");
|
||
}
|
||
});
|
||
form.addEventListener("drop", (e) => {
|
||
form.classList.remove("dropping");
|
||
const files = [...(e.dataTransfer?.files ?? [])];
|
||
if (files.length > 0) {
|
||
e.preventDefault();
|
||
add(files);
|
||
}
|
||
});
|
||
render();
|
||
return {
|
||
el,
|
||
take: () => {
|
||
const imgs = imageDrafts.get(key) ?? [];
|
||
imageDrafts.delete(key);
|
||
render();
|
||
return imgs;
|
||
},
|
||
restore: (imgs) => {
|
||
if (imgs.length > 0) {
|
||
imageDrafts.set(key, [...imgs, ...(imageDrafts.get(key) ?? [])]);
|
||
}
|
||
render();
|
||
},
|
||
};
|
||
}
|
||
async function uploadImages(imgs) {
|
||
if (imgs.length === 0) {
|
||
return undefined;
|
||
}
|
||
const ids = [];
|
||
for (const img of imgs) {
|
||
const blob = await post("/api/blobs", { body: img.file });
|
||
ids.push(blob.id);
|
||
}
|
||
return ids;
|
||
}
|
||
function releaseImages(imgs) {
|
||
for (const img of imgs) {
|
||
URL.revokeObjectURL(img.url);
|
||
}
|
||
}
|
||
const modelChoices = new Map();
|
||
function vendorOf(model) {
|
||
return model.startsWith("gpt-") ? "codex" : "claude";
|
||
}
|
||
function modelSelect(key, current, vendor) {
|
||
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, key, sessionId, sessionState, currentModel, vendor, showPending) {
|
||
const text = h("textarea", { placeholder: src.placeholder(sessionState), rows: 3 });
|
||
const clearDraft = keepDraft(text, `message:${key}`);
|
||
const send = h("button", { type: "submit", disabled: !src.canSend(sessionState) }, "Send");
|
||
const interrupt = h("button", { type: "button", class: "danger", disabled: sessionState !== "running" }, "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, `message:${key}`);
|
||
form.append(tray.el, text, h("div", { class: "compose-actions" }, model, interrupt, send));
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const value = text.value;
|
||
if (!value.trim() && !imageDrafts.has(`message:${key}`)) {
|
||
return;
|
||
}
|
||
const imgs = tray.take();
|
||
text.value = "";
|
||
clearDraft();
|
||
const cancel = showPending(value, imgs);
|
||
try {
|
||
const images = await uploadImages(imgs);
|
||
await src.send({ key, sessionId, text: value, images, model: model.value !== currentModel ? model.value : undefined, firstModel: model.value });
|
||
modelChoices.delete(modelKey);
|
||
}
|
||
catch (err) {
|
||
cancel();
|
||
composerDrafts.set(`message:${key}`, value);
|
||
text.value = value;
|
||
tray.restore(imgs);
|
||
void showAlert(errorText(err));
|
||
}
|
||
};
|
||
submitOnEnter(text, form);
|
||
return form;
|
||
}
|
||
function submitOnEnter(input, form) {
|
||
input.onkeydown = (e) => {
|
||
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
||
e.preventDefault();
|
||
form.requestSubmit();
|
||
}
|
||
};
|
||
}
|
||
export function compactButton(sessionId, sessionState, c) {
|
||
const pct = c && c.window > 0 ? Math.min(100, (c.used / c.window) * 100) : 0;
|
||
const level = pct >= 90 ? "danger" : pct >= 70 ? "warn" : "ok";
|
||
const usage = c && c.window > 0 ? `\ncontext: ${c.used.toLocaleString()} of ${c.window.toLocaleString()} tokens${c.model ? ` (${c.model})` : ""}` : "";
|
||
const b = h("button", {
|
||
type: "button",
|
||
class: `compact ${level}`,
|
||
style: `--fill: ${pct}%`,
|
||
disabled: !sessionId || sessionState !== "idle",
|
||
title: `Ask the agent for a handoff, then continue in a new session with a fresh context${usage}`,
|
||
}, "Compact");
|
||
b.onclick = async () => {
|
||
if (!sessionId) {
|
||
return;
|
||
}
|
||
b.disabled = true;
|
||
try {
|
||
await post("/api/sessions/{sessionId}/compact", { path: { sessionId } });
|
||
}
|
||
catch (err) {
|
||
void showAlert(errorText(err));
|
||
b.disabled = false;
|
||
}
|
||
};
|
||
return b;
|
||
}
|
||
function parentOf(p) {
|
||
return typeof p["parent_tool_use_id"] === "string" ? p["parent_tool_use_id"] : "";
|
||
}
|
||
function subagentTag(parent, ctx) {
|
||
if (!parent) {
|
||
return null;
|
||
}
|
||
const sub = ctx.subagents.get(parent);
|
||
return h("span", { class: "subagent-tag", title: sub?.description ?? "" }, `↳ ${sub?.type ?? "subagent"}`);
|
||
}
|
||
function renderToolCall(p, ctx) {
|
||
const result = h("div", { class: "tool-result pending" }, "running…");
|
||
ctx.tools.set(String(p["id"]), result);
|
||
return h("details", { class: "tool" }, h("summary", {}, subagentTag(parentOf(p), ctx), h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"], ctx.root()))), copyable(h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2))), result);
|
||
}
|
||
function isSubagentCall(p) {
|
||
return p["name"] === "Agent" || p["name"] === "Task";
|
||
}
|
||
function renderSubagent(p, ctx) {
|
||
const input = (typeof p["input"] === "object" && p["input"] !== null ? p["input"] : {});
|
||
const id = String(p["id"]);
|
||
const type = String(input["subagent_type"] ?? "subagent");
|
||
const description = String(input["description"] ?? "");
|
||
const status = h("span", { class: "subagent-status" }, "starting…");
|
||
const result = h("div", { class: "tool-result pending" }, "running…");
|
||
const tag = subagentTag(parentOf(p), ctx);
|
||
ctx.tools.set(id, result);
|
||
ctx.subagents.set(id, { type, description, status });
|
||
return h("details", { class: "tool subagent" }, h("summary", {}, tag, h("span", { class: "tool-name" }, `${p["name"]} ${type}`), " ", h("span", { class: "tool-brief" }, description), " ", status), markdown(String(input["prompt"] ?? ""), "text md subagent-prompt", { taskId: ctx.taskId }), result);
|
||
}
|
||
function claudeTaskStatus(p, ctx) {
|
||
if (p["type"] !== "system") {
|
||
return;
|
||
}
|
||
const sub = ctx.subagents.get(String(p["tool_use_id"]));
|
||
if (!sub) {
|
||
return;
|
||
}
|
||
switch (p["subtype"]) {
|
||
case "task_started":
|
||
replace(sub.status, p["is_backgrounded"] === true ? "running in the background" : "running");
|
||
return;
|
||
case "task_progress":
|
||
replace(sub.status, String(p["description"] ?? "running"));
|
||
return;
|
||
case "task_notification":
|
||
sub.status.className = `subagent-status ${p["status"] === "completed" ? "done" : "error"}`;
|
||
replace(sub.status, String(p["status"]));
|
||
return;
|
||
}
|
||
}
|
||
function markReview(button, r) {
|
||
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: "no findings" }, "✓"));
|
||
return;
|
||
}
|
||
replace(button, "review", h("span", { class: "badge", title: `${n} findings` }, n));
|
||
}
|
||
function snapRow(snap, ctx) {
|
||
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) {
|
||
const counts = new Map();
|
||
for (const c of calls) {
|
||
const key = `${c.name}\0${c.target}`;
|
||
const entry = counts.get(key);
|
||
if (entry) {
|
||
entry.n++;
|
||
}
|
||
else {
|
||
counts.set(key, { call: c, n: 1 });
|
||
}
|
||
}
|
||
if (counts.size === 0) {
|
||
return h("span", { class: "tool-brief" }, "thinking");
|
||
}
|
||
const parts = [];
|
||
for (const { call, n } of counts.values()) {
|
||
if (parts.length > 0) {
|
||
parts.push(", ");
|
||
}
|
||
parts.push(h("span", {}, h("span", { class: "tool-name" }, call.name), call.target ? h("span", { class: "tool-brief" }, " ", call.target) : null, n > 1 ? h("span", { class: "tool-count" }, ` ×${n}`) : null));
|
||
}
|
||
return h("span", {}, ...parts);
|
||
}
|
||
function renderEvent(ev, ctx) {
|
||
const p = ev.payload;
|
||
const md = { taskId: ev.task_id };
|
||
const when = h("time", { dateTime: ev.created_at, title: ev.created_at }, ago(ev.created_at));
|
||
switch (ev.kind) {
|
||
case "user.message":
|
||
ctx.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":
|
||
if (p["handoff"] === true) {
|
||
return h("div", { class: "note" }, "handoff written; continuing in a new session with a fresh context ", when);
|
||
}
|
||
return h("div", { class: `turn-end ${p["is_error"] ? "error" : ""}` }, p["is_error"] ? `turn ended: ${String(p["subtype"]).replaceAll("_", " ")}` : "");
|
||
case "session.created":
|
||
return h("div", { class: "note" }, `${ev.actor} started a ${p["vendor"]} session`, p["model"] ? ` (${p["model"]})` : "", " ", when);
|
||
case "session.model":
|
||
return h("div", { class: "note" }, `${ev.actor} switched the model to ${p["model"]} `, when);
|
||
case "session.state":
|
||
return p["error"] ? h("div", { class: "note error" }, `session ${p["state"]}: ${p["error"]}`) : null;
|
||
case "inbox.created": {
|
||
const kind = p["kind"];
|
||
if (kind === "question") {
|
||
const q = p["payload"];
|
||
return h("div", { class: "note ask" }, `asked${p["blocking"] ? " (blocking)" : ""}: `, String(q["question"]));
|
||
}
|
||
if (kind === "session_error" || kind === "rate_limited") {
|
||
const q = p["payload"];
|
||
return h("div", { class: "note error" }, String(q["error"]));
|
||
}
|
||
return null;
|
||
}
|
||
case "task.created": {
|
||
if (typeof p["forked_from"] !== "string") {
|
||
return null;
|
||
}
|
||
const src = findTask(p["forked_from"]);
|
||
const title = src?.title ?? p["forked_from"];
|
||
const link = src?.status === "active"
|
||
? h("a", {
|
||
href: `#/task/${src.id}`,
|
||
class: "task-link",
|
||
style: taskStyle(src.id),
|
||
onclick: (e) => {
|
||
e.preventDefault();
|
||
focusTask(src.id);
|
||
},
|
||
}, title)
|
||
: title;
|
||
return h("div", { class: "note" }, `${ev.actor} forked this task from `, link, ` at snapshot #${String(p["forked_at"])}; its agent continues that task's conversation as it stood there `, when);
|
||
}
|
||
case "task.merge_title":
|
||
return h("div", { class: "note" }, `${ev.actor} set the merge commit title to "${String(p["merge_title"])}" `, when);
|
||
case "review.finished": {
|
||
const r = p;
|
||
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 "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":
|
||
return h("div", { class: "note" }, `${ev.actor} merged the task${p["commit"] ? ` as ${String(p["commit"]).slice(0, 7)}` : ""}${p["pushed"] ? ` and pushed it to ${String(p["pushed"])}` : ""} `, when);
|
||
case "task.discarded":
|
||
return h("div", { class: "note" }, `${ev.actor} discarded the task `, when);
|
||
}
|
||
return null;
|
||
}
|
||
function messageImages(ids) {
|
||
if (!Array.isArray(ids) || ids.length === 0) {
|
||
return null;
|
||
}
|
||
return h("div", { class: "msg-images" }, ...ids.map((id) => {
|
||
const src = `/api/blobs/${encodeURIComponent(String(id))}`;
|
||
return h("a", { href: src, target: "_blank", rel: "noopener" }, h("img", { src, alt: "attached image", loading: "lazy" }));
|
||
}));
|
||
}
|
||
export async function confirmMerge(taskId) {
|
||
const task = findTask(taskId);
|
||
const sessionState = currentSession(taskId)?.state;
|
||
if (!task || task.status !== "active" || task.merge_queued_at !== undefined || sessionState === "running") {
|
||
return;
|
||
}
|
||
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
|
||
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) {
|
||
return;
|
||
}
|
||
coAuthors = people;
|
||
}
|
||
catch (err) {
|
||
void showAlert(errorText(err));
|
||
return;
|
||
}
|
||
const title = h("input", { type: "text", value: task.merge_title, required: true, maxLength: 72, pattern: ".*\\S.*", class: "modal-field", ariaLabel: "Commit title" });
|
||
const question = [h("p", { class: "modal-text" }, `Merge into ${target} as one commit titled:`), title];
|
||
const trailers = mergeTrailers(coAuthors);
|
||
if (trailers) {
|
||
question.push(h("p", { class: "modal-text" }, trailers));
|
||
}
|
||
await confirmThen(question, "Merge", false, document.querySelector(".merge-button"), async () => {
|
||
await post("/api/tasks/{taskId}/merge", { path: { taskId }, body: { merge_title: title.value } });
|
||
});
|
||
}
|
||
function mergeTrailers(coAuthors) {
|
||
const lines = [];
|
||
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, action, danger, anchor, act) {
|
||
if (!(await showConfirm(question, action, danger, anchor))) {
|
||
return;
|
||
}
|
||
try {
|
||
await act();
|
||
}
|
||
catch (err) {
|
||
await showAlert(errorText(err));
|
||
}
|
||
}
|
||
function toolBrief(input, root) {
|
||
if (typeof input !== "object" || input === null) {
|
||
return "";
|
||
}
|
||
const i = input;
|
||
const brief = i["command"] ?? i["file_path"] ?? i["pattern"] ?? i["url"] ?? i["question"] ?? i["description"] ?? "";
|
||
const text = relative(String(brief), root);
|
||
return text.length > 120 ? `${text.slice(0, 120)}…` : text;
|
||
}
|
||
function toolTarget(input, root) {
|
||
if (typeof input !== "object" || input === null) {
|
||
return "";
|
||
}
|
||
const path = input["file_path"];
|
||
return typeof path === "string" ? relative(path, root) : "";
|
||
}
|
||
function relative(path, root) {
|
||
return root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
|
||
}
|
||
function resultText(content) {
|
||
if (typeof content === "string") {
|
||
return content;
|
||
}
|
||
if (Array.isArray(content)) {
|
||
return content.map((c) => (typeof c === "object" && c !== null && "text" in c ? String(c.text) : JSON.stringify(c))).join("\n");
|
||
}
|
||
return JSON.stringify(content, null, 2);
|
||
}
|
||
//# sourceMappingURL=task.js.map
|