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
+63 -47
View File
@@ -125,7 +125,7 @@ export function mountTranscript(root: HTMLElement): () => void {
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 {
@@ -157,18 +157,43 @@ export function mountTranscript(root: HTMLElement): () => void {
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: { 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) => {
@@ -246,6 +271,9 @@ export function mountTranscript(root: HTMLElement): () => void {
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;
@@ -292,6 +320,7 @@ export function mountTranscript(root: HTMLElement): () => void {
observer.disconnect();
visibleReports.clear();
dropStream();
pending = [];
ctx = newCtx();
context = new Map();
focusStart = true;
@@ -464,34 +493,6 @@ function releaseImages(imgs: PendingImage[]): void {
}
}
function startForm(taskId: string): HTMLElement {
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<string, string>();
function modelSelect(key: string, current: string): HTMLSelectElement {
@@ -504,7 +505,13 @@ function modelSelect(key: string, current: string): HTMLSelectElement {
return select;
}
function messageForm(taskId: string, sessionId: string, sessionState: string, currentModel: string): HTMLElement {
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,
@@ -517,6 +524,9 @@ function messageForm(taskId: string, sessionId: string, sessionState: string, cu
sessionState === "queued" ? "Cancel queued turn" : "Interrupt",
);
interrupt.onclick = async () => {
if (!sessionId) {
return;
}
try {
await post("/api/sessions/{sessionId}/interrupt", { path: { sessionId } });
} catch (err) {
@@ -524,7 +534,8 @@ function messageForm(taskId: string, sessionId: string, sessionState: string, cu
}
};
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));
@@ -537,15 +548,20 @@ function messageForm(taskId: string, sessionId: string, sessionState: string, cu
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);