import { post, type ByreEvent } from "./api.js"; import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js"; import { markdown } from "./markdown.js"; import { currentSession, findTask, onEach, state } from "./state.js"; const models = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"]; type Call = { name: string; target: string }; type Run = { first: HTMLElement; group: { el: HTMLDetailsElement; summary: HTMLElement; body: HTMLElement } | undefined; calls: Call[]; }; type Ctx = { tools: Map; run: Run | undefined; root: string; lastMessage: HTMLElement | undefined; turnStart: HTMLElement | undefined; watchReport: (el: HTMLElement, itemId: string) => void; }; export function mountTranscript(root: HTMLElement): () => void { const head = h("header", { class: "task-header" }); const transcript = h("div", { class: "transcript" }); const composer = h("div", { class: "composer" }); const visibleReports = new Map(); const acked = new Set(); const ackVisible = () => { if (document.visibilityState !== "visible") { return; } const ids = [...new Set(visibleReports.values())].filter((id) => !acked.has(id) && state.inbox.some((i) => i.id === id)); if (ids.length === 0) { return; } for (const id of ids) { acked.add(id); } post("/api/inbox/responses", { body: { responses: ids.map((id) => ({ item_id: id, type: "dismiss" as const })) } }).catch((err) => { for (const id of ids) { acked.delete(id); } console.error("acknowledge reports", err); }); }; const observer = new IntersectionObserver( (entries) => { for (const e of entries) { const id = (e.target as HTMLElement).dataset["inboxItem"]; if (!id) { continue; } if (e.isIntersecting) { visibleReports.set(e.target, id); } else { visibleReports.delete(e.target); } } ackVisible(); }, { root: transcript, threshold: 0.5 }, ); const newCtx = (): Ctx => { const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined; return { tools: new Map(), run: undefined, lastMessage: undefined, turnStart: undefined, root: task?.worktree_path ?? "", watchReport: (el, itemId) => { el.dataset["inboxItem"] = itemId; observer.observe(el); }, }; }; let ctx = newCtx(); let context = new Map(); const spinner = h("div", { class: "spinner", hidden: true }); const setRunning = (running: boolean) => { transcript.classList.toggle("running", running); if (running === !spinner.hidden) { return; } const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80; spinner.hidden = !running; if (pinned) { transcript.scrollTop = transcript.scrollHeight; } }; const put = (el: HTMLElement) => { if (spinner.parentNode === transcript) { transcript.insertBefore(el, spinner); } else { transcript.append(el); } }; const renderHead = () => { const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined; setRunning(task !== undefined && currentSession(task.id)?.state === "running"); if (!task) { replace(head); return; } const sess = currentSession(task.id); if (task.status !== "active") { replace(head, h("div", { class: "head-actions" }, task.status)); return; } const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch"; const busy = sess?.state === "running" || sess?.state === "queued"; const merge = h( "button", { disabled: busy, title: `The agent merges ${target} in, then byre squashes into one commit on ${target}; nothing is pushed` }, "Merge", ); merge.onclick = () => confirmThen(`Merge "${task.title}" into ${target}?`, "Merge", false, async () => { await post("/api/tasks/{taskId}/merge", { path: { taskId: task.id } }); }); const mergeIn = h("button", { disabled: busy, title: `Ask the agent to bring ${target} into this branch and resolve any conflicts` }, `Sync from ${target}`); 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, async () => { await post("/api/tasks/{taskId}/discard", { path: { taskId: task.id } }); location.hash = "#/"; }); replace( head, h("div", { class: "head-actions session-actions" }, sess && contextMeter(context.get(sess.id)), sess && compactButton(sess.id, sess.state), mergeIn), h("div", { class: "head-actions" }, merge, discard), ); }; let composerModel = ""; let focusStart = false; const renderComposer = () => { const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined; if (!task) { replace(composer); return; } const sess = currentSession(task.id); const live = sess && sess.state !== "completed" ? sess : undefined; composerModel = live ? live.model || context.get(live.id)?.model || "" : ""; const form = composeForm(task.id, live?.id, live?.state ?? "idle", composerModel, showPending); replace(composer, form); if (focusStart && !sess) { form.querySelector("textarea")?.focus(); } focusStart = false; }; let pending: { el: HTMLElement; text: string; imgs: PendingImage[] }[] = []; const showPending = (text: string, imgs: PendingImage[]) => { const el = h( "div", { class: "msg user pending" }, h("div", { class: "who" }, "sending…"), imgs.length > 0 ? h("div", { class: "msg-images" }, ...imgs.map((img) => h("img", { src: img.url, alt: "attached image" }))) : null, text.trim() ? markdown(text, "text md") : null, ); const entry = { el, text, imgs }; pending.push(entry); ctx.run = undefined; put(el); transcript.scrollTop = transcript.scrollHeight; return () => { el.remove(); pending = pending.filter((p) => p !== entry); }; }; const confirmPending = (text: string) => { const entry = pending.find((p) => p.text === text); if (!entry) { return; } entry.el.remove(); releaseImages(entry.imgs); pending = pending.filter((p) => p !== entry); }; const addToRun = (el: HTMLElement, call: Call | undefined) => { const run = ctx.run; if (!run) { ctx.run = { first: el, group: undefined, calls: call ? [call] : [] }; put(el); return; } if (call) { run.calls.push(call); } if (!run.group) { const summary = h("summary"); const body = h("div", { class: "tool-group-body" }); const group = h("details", { class: "tool-group" }, summary, body); run.first.replaceWith(group); body.append(run.first); run.group = { el: group, summary, body }; } run.group.body.append(el); replace(run.group.summary, runSummary(run.calls)); }; let stream: { el: HTMLElement; text: string; timer: number } | undefined; const dropStream = () => { if (!stream) { return; } clearTimeout(stream.timer); stream.el.remove(); stream = undefined; }; const appendDelta = (ev: ByreEvent) => { if (!stream) { stream = { el: h("div", { class: "msg agent" }), text: "", timer: 0 }; ctx.run = undefined; put(stream.el); } stream.text += String(ev.payload["text"] ?? ""); if (stream.timer) { return; } stream.timer = setTimeout(() => { if (!stream) { return; } stream.timer = 0; const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80; replace(stream.el, markdown(stream.text, "text md")); if (pinned) { transcript.scrollTop = transcript.scrollHeight; } }, 100); }; const foldTurn = (start: HTMLElement, end: HTMLElement) => { const summary = h("summary"); summary.append(...end.childNodes); const body = h("div", { class: "folded-turn" }); start.before(h("details", { class: "note" }, summary, body)); let node: ChildNode | null = start; while (node && node !== spinner) { const next: ChildNode | null = node.nextSibling; body.append(node); node = next; } }; const appendEvents = (events: ByreEvent[]) => { const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80; let contextChanged = false; for (const ev of events) { const p = ev.payload as Record; if (ev.kind === "agent.message" || ev.kind === "turn.ended" || ev.kind === "user.message") { dropStream(); } if (ev.kind === "user.message" && ev.actor !== "byre") { confirmPending(String(p["text"] ?? "")); } if (ev.kind === "session.context" && ev.agent_session_id) { const prev = context.get(ev.agent_session_id); const window = Number(p["window"]) || prev?.window || 0; context.set(ev.agent_session_id, { used: Number(p["used"]), window, model: String(p["model"] ?? "") }); contextChanged = true; continue; } if (ev.kind === "agent.tool_call") { addToRun(renderToolCall(p, ctx), { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) }); continue; } const el = renderEvent(ev, ctx); if (!el) { continue; } if (ev.kind === "agent.thinking") { addToRun(el, undefined); continue; } ctx.run = undefined; if (ev.kind === "turn.ended" && p["handoff"] === true && ctx.turnStart?.parentNode === transcript) { foldTurn(ctx.turnStart, el); ctx.turnStart = undefined; continue; } put(el); if (ev.kind === "user.message") { ctx.turnStart = el; } } if (contextChanged) { renderHead(); const sess = state.selectedTaskId ? currentSession(state.selectedTaskId) : undefined; if (sess && !sess.model && (context.get(sess.id)?.model ?? "") !== composerModel) { renderComposer(); } } if (pinned) { transcript.scrollTop = transcript.scrollHeight; } }; const renderAll = () => { observer.disconnect(); visibleReports.clear(); dropStream(); pending = []; ctx = newCtx(); context = new Map(); focusStart = true; renderHead(); if (!state.selectedTaskId) { renderComposer(); replace(transcript, h("div", { class: "empty" }, "No task selected. Pick one with ⌘K or from the Tasks tab.")); return; } replace(transcript, spinner); appendEvents(state.transcript); renderComposer(); }; const offs = [ onEach(["selection"], renderAll), onEach(["tasks"], () => { renderHead(); renderComposer(); }), onEach(["sessions"], (taskId) => { if (taskId === state.selectedTaskId) { renderHead(); renderComposer(); } }), onEach(["transcript"], (fresh) => appendEvents(fresh as ByreEvent[])), onEach(["stream"], (ev) => appendDelta(ev as ByreEvent)), onEach(["inbox"], ackVisible), ]; document.addEventListener("visibilitychange", ackVisible); replace(root, h("div", { class: "transcript-view" }, head, transcript, composer)); renderAll(); return () => { for (const off of offs) { off(); } document.removeEventListener("visibilitychange", ackVisible); observer.disconnect(); dropStream(); }; } const composerDrafts = new Map(); function keepDraft(input: HTMLTextAreaElement, key: string): () => void { input.value = composerDrafts.get(key) ?? ""; input.addEventListener("input", () => { if (input.value) { composerDrafts.set(key, input.value); } else { composerDrafts.delete(key); } }); return () => composerDrafts.delete(key); } type PendingImage = { file: Blob; url: string }; const imageDrafts = new Map(); const imageTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"]; const maxImageBytes = 5 << 20; function imageTray(form: HTMLFormElement, input: HTMLTextAreaElement, key: string): { el: HTMLElement; take: () => PendingImage[]; restore: (imgs: PendingImage[]) => void } { const el = h("div", { class: "compose-images" }); const render = () => { const imgs = imageDrafts.get(key) ?? []; el.hidden = imgs.length === 0; replace( el, ...imgs.map((img) => { const remove = h("button", { type: "button", class: "remove", title: "Remove image" }, "×"); remove.onclick = () => { URL.revokeObjectURL(img.url); const rest = (imageDrafts.get(key) ?? []).filter((i) => i !== img); if (rest.length > 0) { imageDrafts.set(key, rest); } else { imageDrafts.delete(key); } render(); }; return h("div", { class: "thumb" }, h("img", { src: img.url, alt: "attached image" }), remove); }), ); }; const add = (files: Iterable) => { const added: PendingImage[] = []; for (const f of files) { if (!imageTypes.includes(f.type)) { void showAlert(`${f.name || "That file"} isn't a PNG, JPEG, GIF or WebP image.`); 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 as Node | null)) { form.classList.remove("dropping"); } }); form.addEventListener("drop", (e) => { form.classList.remove("dropping"); const files = [...(e.dataTransfer?.files ?? [])]; if (files.length > 0) { e.preventDefault(); add(files); } }); render(); return { el, take: () => { const imgs = imageDrafts.get(key) ?? []; imageDrafts.delete(key); render(); return imgs; }, restore: (imgs) => { if (imgs.length > 0) { imageDrafts.set(key, [...imgs, ...(imageDrafts.get(key) ?? [])]); } render(); }, }; } async function uploadImages(imgs: PendingImage[]): Promise { if (imgs.length === 0) { return undefined; } const ids: string[] = []; for (const img of imgs) { const blob = await post("/api/blobs", { body: img.file }); ids.push(blob.id); } return ids; } function releaseImages(imgs: PendingImage[]): void { for (const img of imgs) { URL.revokeObjectURL(img.url); } } const modelChoices = new Map(); function modelSelect(key: string, current: string): HTMLSelectElement { const options = current && !models.includes(current) ? [current, ...models] : models; const select = h("select", { title: "Model for the next turn" }, ...options.map((m) => h("option", { value: m }, m))); select.value = modelChoices.get(key) ?? (current || models[0]!); select.onchange = () => { modelChoices.set(key, select.value); }; return select; } function composeForm( taskId: string, sessionId: string | undefined, sessionState: string, currentModel: string, showPending: (text: string, imgs: PendingImage[]) => () => void, ): HTMLElement { const text = h("textarea", { placeholder: sessionState === "idle" ? "Message the agent. Paste or drop images to attach them." : "Wait for the turn to end, or interrupt it", rows: 3, }); const clearDraft = keepDraft(text, `message:${taskId}`); const send = h("button", { type: "submit", disabled: sessionState !== "idle" }, "Send"); const interrupt = h( "button", { type: "button", class: "danger", disabled: sessionState !== "running" && sessionState !== "queued" }, sessionState === "queued" ? "Cancel queued turn" : "Interrupt", ); interrupt.onclick = async () => { if (!sessionId) { return; } try { await post("/api/sessions/{sessionId}/interrupt", { path: { sessionId } }); } catch (err) { void showAlert(errorText(err)); } }; const modelKey = `message:${sessionId ?? taskId}`; const model = modelSelect(modelKey, currentModel); const form = h("form", { class: "compose" }); const tray = imageTray(form, text, `message:${taskId}`); form.append(tray.el, text, h("div", { class: "compose-actions" }, model, interrupt, send)); form.onsubmit = async (e) => { e.preventDefault(); const value = text.value; if (!value.trim() && !imageDrafts.has(`message:${taskId}`)) { return; } const imgs = tray.take(); text.value = ""; clearDraft(); const cancel = showPending(value, imgs); try { const images = await uploadImages(imgs); if (sessionId) { await post("/api/sessions/{sessionId}/messages", { path: { sessionId }, body: { text: value, images, model: model.value !== currentModel ? model.value : undefined }, }); } else { await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: value, model: model.value, images } }); } modelChoices.delete(modelKey); } catch (err) { cancel(); composerDrafts.set(`message:${taskId}`, value); text.value = value; tray.restore(imgs); void showAlert(errorText(err)); } }; submitOnEnter(text, form); return form; } function submitOnEnter(input: HTMLTextAreaElement, form: HTMLFormElement): void { input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); } }; } function compactButton(sessionId: string, sessionState: string): HTMLElement { const b = h( "button", { type: "button", disabled: sessionState !== "idle", title: "Ask the agent for a handoff, then continue in a new session with a fresh context" }, "Compact", ); b.onclick = async () => { b.disabled = true; try { await post("/api/sessions/{sessionId}/compact", { path: { sessionId } }); } catch (err) { void showAlert(errorText(err)); b.disabled = false; } }; return b; } function contextMeter(c: { used: number; window: number; model: string } | undefined): HTMLElement | null { if (!c || c.window <= 0) { return null; } const pct = Math.min(100, (c.used / c.window) * 100); const level = pct >= 90 ? "danger" : pct >= 70 ? "warn" : "ok"; const title = `context: ${c.used.toLocaleString()} of ${c.window.toLocaleString()} tokens${c.model ? ` (${c.model})` : ""}`; return h("span", { class: `context-meter ${level}`, title }, `${Math.round(pct)}%`); } function renderToolCall(p: Record, ctx: Ctx): HTMLElement { const result = h("div", { class: "tool-result pending" }, "running…"); ctx.tools.set(String(p["id"]), result); return h( "details", { class: "tool" }, h("summary", {}, h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"], ctx.root))), h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2)), result, ); } function runSummary(calls: Call[]): HTMLElement { const counts = new Map(); 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; const when = h("time", { dateTime: ev.created_at, title: ev.created_at }, ago(ev.created_at)); switch (ev.kind) { case "user.message": ctx.lastMessage = undefined; if (ev.actor === "byre") { const text = String(p["text"]); const [first, ...rest] = text.split("\n"); if (rest.join("").trim() === "") { return h("div", { class: "note" }, text, " ", when); } return h("details", { class: "note" }, h("summary", {}, first, " ", when), markdown(rest.join("\n"), "text 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") : null, ); case "agent.message": ctx.lastMessage = h("div", { class: "msg agent" }, markdown(String(p["text"]), "text md")); return ctx.lastMessage; case "agent.thinking": return h("details", { class: "thinking" }, h("summary", {}, "thinking"), markdown(String(p["text"]), "text md")); case "agent.tool_result": { const target = ctx.tools.get(String(p["tool_use_id"])); const body = h("pre", {}, resultText(p["content"])); if (!target) { return h("div", { class: "tool-result orphan" }, body); } target.className = "tool-result"; replace(target, body); return null; } case "turn.ended": if (typeof p["inbox_item_id"] === "string" && p["inbox_item_id"] && ctx.lastMessage) { ctx.watchReport(ctx.lastMessage, p["inbox_item_id"]); } if (p["handoff"] === true) { return h("div", { class: "note" }, "handoff written; continuing in a new session with a fresh context ", when); } return h("div", { class: `turn-end ${p["is_error"] ? "error" : ""}` }, p["is_error"] ? `turn ended: ${String(p["subtype"]).replaceAll("_", " ")}` : ""); case "session.created": return h("div", { class: "note" }, `${ev.actor} started a ${p["vendor"]} session`, p["model"] ? ` (${p["model"]})` : "", " ", when); 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; return h("div", { class: "note ask" }, `asked${p["blocking"] ? " (blocking)" : ""}: `, String(q["question"])); } if (kind === "session_error" || kind === "rate_limited") { const q = p["payload"] as Record; return h("div", { class: "note error" }, String(q["error"])); } return null; } case "task.merge_title": return h("div", { class: "note" }, `${ev.actor} set the merge commit title to "${String(p["merge_title"])}" `, when); 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: unknown): HTMLElement | null { if (!Array.isArray(ids) || ids.length === 0) { return null; } return h( "div", { class: "msg-images" }, ...ids.map((id) => { const src = `/api/blobs/${encodeURIComponent(String(id))}`; return h("a", { href: src, target: "_blank", rel: "noopener" }, h("img", { src, alt: "attached image", loading: "lazy" })); }), ); } async function confirmThen(question: string, action: string, danger: boolean, act: () => Promise) { if (!(await showConfirm(question, action, danger))) { 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; 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)["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); }