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
+2 -2
View File
@@ -22,7 +22,7 @@ Schema changes are the `migrations` slice in `internal/store/store.go`: named en
A task ends `merged` or `discarded`; either way its sessions are stopped, its worktree and branch are removed, its `open` inbox items become `dismissed` (resolved by whoever closed it), and its record and events remain. `GET /api/tasks/{id}/unmerged` reports what would be lost or merged: commits on the branch that aren't on the default branch, and files, lines and uncommitted paths that differ from the merge base. The UI's Merge and Discard buttons each ask a one-line confirmation and are disabled while the task's session is `running` or `queued`.
`POST /api/tasks/{id}/merge-in` (the transcript's **Merge main in** button, labelled with the default branch) brings the default branch into the task branch through the agent: it sends `mergeInPrompt` (`internal/agents/mergein.go`) as a `byre` message to the task's session, or starts a session with it when the task has none. The prompt has the agent run `git merge --no-ff --no-commit`, resolve conflicts, regenerate generated files rather than hand-merging them, check the build and tests, and leave the merge uncommitted; byre's end-of-turn commit concludes it as a merge commit. The prompt ends by showing the task's current merge title and asking the agent to set a better one with `set_merge_title` if it no longer describes all the work on the branch. It is refused (409) while a session is `running` or `queued`.
`POST /api/tasks/{id}/merge-in` (the transcript's **Sync from main** button, labelled with the default branch) brings the default branch into the task branch through the agent: it sends `mergeInPrompt` (`internal/agents/mergein.go`) as a `byre` message to the task's session, or starts a session with it when the task has none. The prompt has the agent run `git merge --no-ff --no-commit`, resolve conflicts, regenerate generated files rather than hand-merging them, check the build and tests, and leave the merge uncommitted; byre's end-of-turn commit concludes it as a merge commit. The prompt ends by showing the task's current merge title and asking the agent to set a better one with `set_merge_title` if it no longer describes all the work on the branch. It is refused (409) while a session is `running` or `queued`.
A task's `merge_title` is the title of the squash commit Merge makes. It is the task title until the agent calls the `set_merge_title` MCP tool (`internal/agents/mergein.go`), which it may do at any point: one line of at most 72 characters (`maxMergeTitle`), stored in `tasks.merge_title` (empty there means "use the title"), recorded as a `task.merge_title` event that the transcript shows as a note. Malformed titles are rejected with a corrective message, and so is a call once the task is closed. The Merge confirm dialog quotes the title the commit will get.
@@ -96,7 +96,7 @@ The Admin view (a pane tab, also opened in the right pane by the navbar's **Admi
Agent activity becomes events with `agent_session_id` set: `user.message`, `agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`, `turn.ended`, `session.state`, `session.model`, and `session.context`. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as `agent.vendor`. The one exception is partial output: each top-level `text_delta` is broadcast to SSE clients as an `agent.delta` event (`payload.text`, `seq` 0) that is never stored, and the transcript grows a live message from them until the finished `agent.message` replaces it. A `turn.ended` event carries `inbox_item_id` when its final message was filed as a `report` item. `session.context` records the context size whenever it changes: `used` is the last assistant message's input, cache-read, cache-creation and output tokens, and `window` is the model's context window from the turn's `result` (`modelUsage[model].contextWindow`), so it is 0 until the session's first turn after a server start ends. The task header shows the latest one as a percentage of the window, coloured green, amber from 70%, red from 90%.
Messages byre itself sends an agent (actor `byre`, such as the restart notice) show as a muted note, not a user bubble. The transcript view has an accent border while the session is `running`, and a braille spinner on its last line. The spinner is pure CSS: six dots laid out as a braille cell, each switched on and off by its own `step-end` keyframes (`braille-1` to `braille-6` in `web/style.css`) so together they cycle ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ every 0.8s. Browsers that freeze animated GIFs (Orion) still run it. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. A turn's final message that is at least half visible in the transcript while the page is visible dismisses its `report` inbox item, as the viewing user. Selecting a task that has never had a session focuses its start prompt. In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task in memory, so it survives switching tabs or tasks but not a page reload.
Messages byre itself sends an agent (actor `byre`, such as the restart notice) show as a muted note, not a user bubble. The transcript view has an accent border while the session is `running`, and a braille spinner on its last line. The spinner is pure CSS: six dots laid out as a braille cell, each switched on and off by its own `step-end` keyframes (`braille-1` to `braille-6` in `web/style.css`) so together they cycle ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ every 0.8s. Browsers that freeze animated GIFs (Orion) still run it. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. A turn's final message that is at least half visible in the transcript while the page is visible dismisses its `report` inbox item, as the viewing user. Selecting a task that has never had a session focuses the composer. In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task in memory, so it survives switching tabs or tasks but not a page reload. The composer is the same whether or not the task has a live session: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a "sending…" bubble, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails.
The task switcher (⌘K, or the navbar crumb) lists active tasks with their session state and open inbox item count, live while it is open. Tasks with inbox items come first; otherwise the order is the server's (projects by name, tasks newest first). Typing filters them and adds a last row that creates a task with the typed text as its title, in the current task's project (the first project when no task is selected); Enter picks the highlighted row. Picking a task here or in the Tasks view selects it and, if neither pane shows the Transcript tab, switches a pane that isn't showing Tasks to it.
+1 -1
View File
@@ -484,7 +484,7 @@ Record transcripts from the spike as the seed of the contract test suite.
- **Not done:** snapshots, timeline scrubbing, terminals, file browser, previews.
- **M3 — Second vendor and review.** Codex adapter with the same interface coverage, review profiles, review triggers, visual review output, cross-vendor review.
- **M4 — Merging and history.** Full merge cycle with squash, authorship and co-author trailers, drafted commit messages, merge-resolution stage, git notes, task completion and archive, archive search, GitHub Issues integration.
- **Done:** Merge and Discard replace "Complete task". Merge squashes the worktree, uncommitted files included, into one commit on the default branch, authored by the merging user, pushes it to the project's `origin`, and then fast-forwards the project checkout; it refuses while a turn is running. Merge first has the agent merge the default branch in (also available alone as "Merge main in"), then squashes when that turn ends. Merge fetches the remote's default branch into the checkout before the merge-in; a refused or failed push is filed as an error and the task stays active for another Merge. The squash commit's title is the task's merge title: the task title until the agent sets another with the `set_merge_title` MCP tool, which the merge-in prompt asks it to do when the current title no longer describes the branch.
- **Done:** Merge and Discard replace "Complete task". Merge squashes the worktree, uncommitted files included, into one commit on the default branch, authored by the merging user, pushes it to the project's `origin`, and then fast-forwards the project checkout; it refuses while a turn is running. Merge first has the agent merge the default branch in (also available alone as "Sync from main"), then squashes when that turn ends. Merge fetches the remote's default branch into the checkout before the merge-in; a refused or failed push is filed as an error and the task stays active for another Merge. The squash commit's title is the task's merge title: the task title until the agent sets another with the `set_merge_title` MCP tool, which the merge-in prompt asks it to do when the current title no longer describes the branch.
- **Not done:** co-author trailers, human editing of the drafted message before confirming, merge-resolution stage, git notes (and pushing the notes ref), repeated merge cycles, archive search, GitHub Issues.
- **M5 — Workflows.** Workflow definitions and storage, stages, gates, bounded loops, plan artifact, eval stages, task intake, mid-task swapping.
- **M6 — Fan-out.** `export_context` and handoff bundles, same-vendor forks, cross-vendor handoffs, fork-and-compare, just-in-time fan-out with limits.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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);
+1 -1
View File
File diff suppressed because one or more lines are too long
+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);
+4
View File
@@ -672,6 +672,10 @@ textarea {
border-radius: 10px;
}
.msg.user.pending .who {
font-style: italic;
}
.who {
font-size: 11px;
color: var(--muted);