Bold questions
This commit is contained in:
Vendored
+164
-53
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user