Instant message feedback, one composer, rename to "Sync from main"

This commit is contained in:
Ian Gulliver
2026-10-14 22:50:13 -07:00
parent 8825d38695
commit c3ed243573
13 changed files with 225 additions and 198 deletions
+51 -47
View File
@@ -96,7 +96,7 @@ export function mountTranscript(root) {
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 merge ${target} into this branch and resolve any conflicts` }, `Merge ${target} in`);
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 {
@@ -123,18 +123,36 @@ export function mountTranscript(root) {
return;
}
const sess = currentSession(task.id);
if (!sess || sess.state === "completed") {
const form = startForm(task.id);
replace(composer, form);
if (focusStart && !sess) {
form.querySelector("textarea")?.focus();
}
focusStart = false;
return;
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;
composerModel = sess.model || context.get(sess.id)?.model || "";
replace(composer, messageForm(task.id, sess.id, sess.state, composerModel));
};
let pending = [];
const showPending = (text, imgs) => {
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) => {
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, call) => {
const run = ctx.run;
@@ -208,6 +226,9 @@ export function mountTranscript(root) {
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;
@@ -253,6 +274,7 @@ export function mountTranscript(root) {
observer.disconnect();
visibleReports.clear();
dropStream();
pending = [];
ctx = newCtx();
context = new Map();
focusStart = true;
@@ -410,34 +432,6 @@ function releaseImages(imgs) {
URL.revokeObjectURL(img.url);
}
}
function startForm(taskId) {
const prompt = h("textarea", { placeholder: "What should the agent do? Paste or drop images to attach them.", rows: 4, required: true });
const clearDraft = keepDraft(prompt, `start:${taskId}`);
const model = modelSelect(`start:${taskId}`, models[0]);
const form = h("form", { class: "compose" });
const tray = imageTray(form, prompt, `start:${taskId}`);
form.append(tray.el, prompt, h("div", { class: "compose-actions" }, model, h("button", { type: "submit" }, "Start agent")));
form.onsubmit = async (e) => {
e.preventDefault();
const value = prompt.value;
const imgs = tray.take();
prompt.value = "";
clearDraft();
try {
const images = await uploadImages(imgs);
await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: value, model: model.value, images } });
releaseImages(imgs);
}
catch (err) {
composerDrafts.set(`start:${taskId}`, value);
prompt.value = value;
tray.restore(imgs);
void showAlert(errorText(err));
}
};
submitOnEnter(prompt, form);
return form;
}
const modelChoices = new Map();
function modelSelect(key, current) {
const options = current && !models.includes(current) ? [current, ...models] : models;
@@ -448,7 +442,7 @@ function modelSelect(key, current) {
};
return select;
}
function messageForm(taskId, sessionId, sessionState, currentModel) {
function composeForm(taskId, sessionId, sessionState, currentModel, showPending) {
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,
@@ -457,6 +451,9 @@ function messageForm(taskId, sessionId, sessionState, currentModel) {
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 } });
}
@@ -464,7 +461,8 @@ function messageForm(taskId, sessionId, sessionState, currentModel) {
void showAlert(errorText(err));
}
};
const model = modelSelect(`message:${sessionId}`, currentModel);
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));
@@ -477,16 +475,22 @@ function messageForm(taskId, sessionId, sessionState, currentModel) {
const imgs = tray.take();
text.value = "";
clearDraft();
const cancel = showPending(value, imgs);
try {
const images = await uploadImages(imgs);
await post("/api/sessions/{sessionId}/messages", {
path: { sessionId },
body: { text: value, images, model: model.value !== currentModel ? model.value : undefined },
});
modelChoices.delete(`message:${sessionId}`);
releaseImages(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);