Bold questions

This commit is contained in:
Ian Gulliver
2026-10-14 19:35:34 -07:00
parent ab4c3d2c6f
commit 1ea055682b
54 changed files with 3696 additions and 762 deletions
+164 -53
View File
@@ -1,8 +1,8 @@
import { post } from "./api.js";
import { get, post } from "./api.js";
import { ago, errorText, h, replace } from "./dom.js";
import { inlineMarkdown, markdown } from "./markdown.js";
import { markdown } from "./markdown.js";
import { currentSession, findTask, onEach, state } from "./state.js";
const models = ["", "opus", "sonnet", "haiku"];
const models = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"];
export function mountTranscript(root) {
const head = h("header", { class: "task-header" });
const transcript = h("div", { class: "transcript" });
@@ -47,6 +47,8 @@ export function mountTranscript(root) {
return {
tools: new Map(),
run: undefined,
lastMessage: undefined,
turnStart: undefined,
root: task?.worktree_path ?? "",
watchReport: (el, itemId) => {
el.dataset["inboxItem"] = itemId;
@@ -56,7 +58,7 @@ export function mountTranscript(root) {
};
let ctx = newCtx();
let context = new Map();
const spinner = h("div", { class: "spinner", hidden: true });
const spinner = h("div", { class: "spinner", hidden: true }, ...[1, 2, 3, 4, 5, 6].map((n) => h("span", { class: `dot${n}` })));
const setRunning = (running) => {
transcript.classList.toggle("running", running);
if (running === !spinner.hidden) {
@@ -84,21 +86,18 @@ export function mountTranscript(root) {
return;
}
const sess = currentSession(task.id);
const complete = h("button", { class: "danger", title: "Remove the worktree and branch; history stays" }, "Complete task");
complete.onclick = async () => {
if (!confirm(`Complete "${task.title}"? Its worktree and branch are removed; its history stays.`)) {
return;
}
try {
await post("/api/tasks/{taskId}/complete", { path: { taskId: task.id } });
location.hash = "#/";
}
catch (err) {
alert(errorText(err));
}
};
replace(head, h("div", { class: "meta" }, h("code", {}, task.branch)), h("div", { class: "head-actions" }, sess && contextMeter(context.get(sess.id)), complete));
if (task.status !== "active") {
replace(head, h("div", { class: "meta" }, h("code", {}, task.branch)), 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 merge = h("button", { title: `Squash into one commit on ${target}; nothing is pushed` }, "Merge");
merge.onclick = () => closeTask(task, (u) => `Merge "${task.title}" into ${target}? ${changesText(u)} become one commit on ${target}. Nothing is pushed.`, "/api/tasks/{taskId}/merge");
const discard = h("button", { class: "danger", title: "Delete the worktree and branch without merging" }, "Discard");
discard.onclick = () => closeTask(task, (u) => `Discard "${task.title}"? ${changesText(u)} that aren't on ${target} will be deleted. This can't be undone.`, "/api/tasks/{taskId}/discard");
replace(head, h("div", { class: "meta" }, h("code", {}, task.branch)), h("div", { class: "head-actions" }, sess && contextMeter(context.get(sess.id)), sess && compactButton(sess.id, sess.state), merge, discard));
};
let composerModel = "";
const renderComposer = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
if (!task) {
@@ -110,7 +109,8 @@ export function mountTranscript(root) {
replace(composer, startForm(task.id));
return;
}
replace(composer, messageForm(task.id, sess.id, sess.state));
composerModel = sess.model || context.get(sess.id)?.model || "";
replace(composer, messageForm(task.id, sess.id, sess.state, composerModel));
};
const addToRun = (el, call) => {
const run = ctx.run;
@@ -136,11 +136,57 @@ export function mountTranscript(root) {
run.group.body.append(el);
replace(run.group.summary, runSummary(run.calls));
};
let stream;
const dropStream = () => {
if (!stream) {
return;
}
cancelAnimationFrame(stream.frame);
stream.el.remove();
stream = undefined;
};
const appendDelta = (ev) => {
if (!stream) {
stream = { el: h("div", { class: "msg agent" }), text: "", frame: 0 };
ctx.run = undefined;
put(stream.el);
}
stream.text += String(ev.payload["text"] ?? "");
if (stream.frame) {
return;
}
stream.frame = requestAnimationFrame(() => {
if (!stream) {
return;
}
stream.frame = 0;
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
replace(stream.el, markdown(stream.text, "text md"));
if (pinned) {
transcript.scrollTop = transcript.scrollHeight;
}
});
};
const foldTurn = (start, end) => {
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 = start;
while (node && node !== spinner) {
const next = node.nextSibling;
body.append(node);
node = next;
}
};
const appendEvents = (events) => {
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
let contextChanged = false;
for (const ev of events) {
const p = ev.payload;
if (ev.kind === "agent.message" || ev.kind === "turn.ended" || ev.kind === "user.message") {
dropStream();
}
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;
@@ -161,10 +207,22 @@ export function mountTranscript(root) {
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;
@@ -173,16 +231,18 @@ export function mountTranscript(root) {
const renderAll = () => {
observer.disconnect();
visibleReports.clear();
dropStream();
ctx = newCtx();
context = new Map();
renderHead();
renderComposer();
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),
@@ -197,6 +257,7 @@ export function mountTranscript(root) {
}
}),
onEach(["transcript"], (fresh) => appendEvents(fresh)),
onEach(["stream"], (ev) => appendDelta(ev)),
onEach(["inbox"], ackVisible),
];
document.addEventListener("visibilitychange", ackVisible);
@@ -208,6 +269,7 @@ export function mountTranscript(root) {
}
document.removeEventListener("visibilitychange", ackVisible);
observer.disconnect();
dropStream();
};
}
const composerDrafts = new Map();
@@ -226,22 +288,36 @@ function keepDraft(input, key) {
function startForm(taskId) {
const prompt = h("textarea", { placeholder: "What should the agent do?", rows: 4, required: true });
const clearDraft = keepDraft(prompt, `start:${taskId}`);
const model = h("select", {}, ...models.map((m) => h("option", { value: m }, m || "default model")));
const model = modelSelect(`start:${taskId}`, models[0]);
const form = h("form", { class: "compose" }, prompt, h("div", { class: "compose-actions" }, model, h("button", { type: "submit" }, "Start agent")));
form.onsubmit = async (e) => {
e.preventDefault();
const value = prompt.value;
prompt.value = "";
clearDraft();
try {
await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: prompt.value, model: model.value || undefined } });
clearDraft();
await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: value, model: model.value } });
}
catch (err) {
composerDrafts.set(`start:${taskId}`, value);
prompt.value = value;
alert(errorText(err));
}
};
submitOnEnter(prompt, form);
return form;
}
function messageForm(taskId, sessionId, sessionState) {
const modelChoices = new Map();
function modelSelect(key, current) {
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 messageForm(taskId, sessionId, sessionState, currentModel) {
const text = h("textarea", { placeholder: sessionState === "idle" ? "Message the agent" : "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");
@@ -254,18 +330,26 @@ function messageForm(taskId, sessionId, sessionState) {
alert(errorText(err));
}
};
const form = h("form", { class: "compose" }, text, h("div", { class: "compose-actions" }, interrupt, send));
const model = modelSelect(`message:${sessionId}`, currentModel);
const form = h("form", { class: "compose" }, text, h("div", { class: "compose-actions" }, model, interrupt, send));
form.onsubmit = async (e) => {
e.preventDefault();
if (!text.value.trim()) {
const value = text.value;
if (!value.trim()) {
return;
}
text.value = "";
clearDraft();
try {
await post("/api/sessions/{sessionId}/messages", { path: { sessionId }, body: { text: text.value } });
text.value = "";
clearDraft();
await post("/api/sessions/{sessionId}/messages", {
path: { sessionId },
body: { text: value, model: model.value !== currentModel ? model.value : undefined },
});
modelChoices.delete(`message:${sessionId}`);
}
catch (err) {
composerDrafts.set(`message:${taskId}`, value);
text.value = value;
alert(errorText(err));
}
};
@@ -280,6 +364,20 @@ function submitOnEnter(input, form) {
}
};
}
function compactButton(sessionId, sessionState) {
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) {
alert(errorText(err));
b.disabled = false;
}
};
return b;
}
function contextMeter(c) {
if (!c || c.window <= 0) {
return null;
@@ -323,12 +421,19 @@ function renderEvent(ev, ctx) {
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") {
return h("div", { class: "note" }, String(p["text"]), " ", when);
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), markdown(String(p["text"]), "text md"));
case "agent.message":
return h("div", { class: "msg agent" }, markdown(String(p["text"]), "text md"));
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": {
@@ -345,17 +450,18 @@ function renderEvent(ev, ctx) {
}
return null;
}
case "agent.report": {
const card = reportCard(p);
if (typeof p["inbox_item_id"] === "string" && p["inbox_item_id"]) {
ctx.watchReport(card, p["inbox_item_id"]);
}
return card;
}
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": {
@@ -370,25 +476,30 @@ function renderEvent(ev, ctx) {
}
return null;
}
case "task.completed":
return h("div", { class: "note" }, `${ev.actor} completed the task `, when);
case "task.merged":
return h("div", { class: "note" }, `${ev.actor} merged the task${p["commit"] ? ` as ${String(p["commit"]).slice(0, 7)}` : ""} `, when);
case "task.discarded":
return h("div", { class: "note" }, `${ev.actor} discarded the task `, when);
}
return null;
}
export function reportCard(p) {
const sections = [
["done", "Done"],
["deviations", "Deviations from plan"],
["risks", "Risks"],
["next_steps", "Next steps"],
];
return h("div", { class: "report" }, ...sections.map(([key, label]) => {
const items = p[key] ?? [];
if (items.length === 0) {
return null;
async function closeTask(task, prompt, path) {
try {
const u = await get("/api/tasks/{taskId}/unmerged", { path: { taskId: task.id } });
if (!confirm(prompt(u))) {
return;
}
return h("div", { class: `report-section ${key}` }, h("h4", {}, label), h("ul", {}, ...items.map((i) => h("li", {}, inlineMarkdown(i)))));
}));
await post(path, { path: { taskId: task.id } });
location.hash = "#/";
}
catch (err) {
alert(errorText(err));
}
}
function changesText(u) {
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
const uncommitted = u.uncommitted > 0 ? `, ${u.uncommitted} uncommitted` : "";
return `${plural(u.commits, "commit")} and ${plural(u.files, "changed file")} (+${u.insertions} −${u.deletions}${uncommitted})`;
}
function toolBrief(input, root) {
if (typeof input !== "object" || input === null) {