Add a per-user lead agent and make the right pane task-focused

This commit is contained in:
Ian Gulliver
2026-10-15 09:40:30 -07:00
parent d212590cdc
commit bdd1ae48f0
68 changed files with 3332 additions and 1364 deletions
+154 -130
View File
@@ -1,10 +1,117 @@
import { get, post } from "./api.js";
import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
import { markdown } from "./markdown.js";
import { openChanges } from "./panes.js";
import { currentSession, findTask, onEach, pinSnapshot, state } from "./state.js";
import { currentSession, findTask, focusTab, onEach, pinSnapshot, setFocus, state } from "./state.js";
export const models = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"];
const taskSource = {
key: () => state.selectedTaskId,
events: () => state.transcript,
session: () => (state.selectedTaskId ? currentSession(state.selectedTaskId) : undefined),
root: () => (state.selectedTaskId ? (findTask(state.selectedTaskId)?.worktree_path ?? "") : ""),
taskId: () => state.selectedTaskId,
resetTopics: ["selection"],
refreshTopics: ["tasks", "sessions"],
transcriptTopic: "transcript",
streamTopic: "stream",
empty: "No task selected. Ask your lead, or pick one with ⌘K.",
canSend: (s) => s === "idle",
placeholder: (s) => (s === "idle" ? "Message the agent. Paste or drop images to attach them." : "Wait for the turn to end, or interrupt it"),
send: async ({ key, sessionId, text, images, model, firstModel }) => {
if (sessionId) {
await post("/api/sessions/{sessionId}/messages", { path: { sessionId }, body: { text, images, model } });
return;
}
await post("/api/tasks/{taskId}/sessions", { path: { taskId: key }, body: { prompt: text, model: firstModel, images } });
},
};
export function mountTranscript(root) {
return mountChat(root, taskSource, taskHead);
}
function taskHead(rerender) {
let unmerged;
let timer;
const load = async () => {
const taskId = state.selectedTaskId;
if (!taskId) {
return;
}
try {
const u = await get("/api/tasks/{taskId}/unmerged", { path: { taskId } });
if (state.selectedTaskId !== taskId) {
return;
}
const has = u.commits > 0 || u.files > 0;
if (unmerged?.taskId === taskId && unmerged.has === has) {
return;
}
unmerged = { taskId, has };
rerender();
}
catch (err) {
console.error("load unmerged changes", err);
}
};
return {
reset: () => {
clearTimeout(timer);
void load();
},
dispose: () => clearTimeout(timer),
onEvents: (events) => {
if (events.some((ev) => ev.kind === "agent.tool_result" || ev.kind === "turn.ended")) {
clearTimeout(timer);
timer = setTimeout(() => void load(), 1500);
}
},
render: (usage) => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
if (!task) {
return [];
}
const sess = currentSession(task.id);
if (task.status !== "active") {
return [h("div", { class: "head-actions" }, task.status)];
}
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
const busy = sess?.state === "running" || sess?.state === "queued";
const merge = h("button", { disabled: busy || unmerged?.taskId !== task.id || !unmerged.has, title: `The agent merges ${target} in, then byre squashes into one commit on ${target} and pushes it to origin` }, "Merge");
merge.onclick = async () => {
let coAuthors;
try {
coAuthors = await get("/api/tasks/{taskId}/co-authors", { path: { taskId: task.id } });
}
catch (err) {
void showAlert(errorText(err));
return;
}
await confirmThen(mergeQuestion(task.merge_title, target, coAuthors), "Merge", false, async () => {
await post("/api/tasks/{taskId}/merge", { path: { taskId: task.id } });
});
};
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 {
await post("/api/tasks/{taskId}/merge-in", { path: { taskId: task.id } });
}
catch (err) {
void showAlert(errorText(err));
mergeIn.disabled = false;
}
};
const discard = h("button", { class: "danger", disabled: busy, title: "Delete the worktree and branch without merging" }, "Discard");
discard.onclick = () => confirmThen(`Discard "${task.title}"?`, "Discard", true, async () => {
await post("/api/tasks/{taskId}/discard", { path: { taskId: task.id } });
await setFocus({ task_id: null });
});
return [
h("div", { class: "head-actions session-actions" }, compactButton(sess?.id, sess?.state, sess && usage(sess.id)), mergeIn),
h("div", { class: "head-actions" }, merge, discard),
];
},
};
}
export function mountChat(root, src, makeHead) {
const head = h("header", { class: "task-header" });
const transcript = h("div", { class: "transcript" });
const composer = h("div", { class: "composer" });
@@ -43,47 +150,23 @@ export function mountTranscript(root) {
}
ackVisible();
}, { root: transcript, threshold: 0.5 });
const newCtx = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
return {
tools: new Map(),
run: undefined,
anchors: [],
unplaced: [],
lastMessage: undefined,
turnStart: undefined,
root: task?.worktree_path ?? "",
watchReport: (el, itemId) => {
el.dataset["inboxItem"] = itemId;
observer.observe(el);
},
};
};
const newCtx = () => ({
tools: new Map(),
run: undefined,
anchors: [],
unplaced: [],
lastMessage: undefined,
turnStart: undefined,
root: src.root(),
taskId: src.taskId(),
watchReport: (el, itemId) => {
el.dataset["inboxItem"] = itemId;
observer.observe(el);
},
});
let ctx = newCtx();
let context = new Map();
let unmerged;
let unmergedTimer;
const loadUnmerged = async () => {
const taskId = state.selectedTaskId;
if (!taskId) {
return;
}
try {
const u = await get("/api/tasks/{taskId}/unmerged", { path: { taskId } });
if (state.selectedTaskId !== taskId) {
return;
}
const has = u.commits > 0 || u.files > 0;
if (unmerged?.taskId === taskId && unmerged.has === has) {
return;
}
unmerged = { taskId, has };
renderHead();
}
catch (err) {
console.error("load unmerged changes", err);
}
};
const headImpl = makeHead(() => renderHead());
const spinner = h("div", { class: "spinner", hidden: true });
const setRunning = (running) => {
transcript.classList.toggle("running", running);
@@ -109,63 +192,25 @@ export function mountTranscript(root) {
};
const put = (el) => putBefore(el, pending[0]?.el);
const renderHead = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
setRunning(task !== undefined && currentSession(task.id)?.state === "running");
if (!task) {
setRunning(src.session()?.state === "running");
if (!src.key()) {
replace(head);
return;
}
const sess = currentSession(task.id);
if (task.status !== "active") {
replace(head, 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 busy = sess?.state === "running" || sess?.state === "queued";
const merge = h("button", { disabled: busy || unmerged?.taskId !== task.id || !unmerged.has, title: `The agent merges ${target} in, then byre squashes into one commit on ${target} and pushes it to origin` }, "Merge");
merge.onclick = async () => {
let coAuthors;
try {
coAuthors = await get("/api/tasks/{taskId}/co-authors", { path: { taskId: task.id } });
}
catch (err) {
void showAlert(errorText(err));
return;
}
await confirmThen(mergeQuestion(task.merge_title, target, coAuthors), "Merge", false, async () => {
await post("/api/tasks/{taskId}/merge", { path: { taskId: task.id } });
});
};
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 {
await post("/api/tasks/{taskId}/merge-in", { path: { taskId: task.id } });
}
catch (err) {
void showAlert(errorText(err));
mergeIn.disabled = false;
}
};
const discard = h("button", { class: "danger", disabled: busy, title: "Delete the worktree and branch without merging" }, "Discard");
discard.onclick = () => confirmThen(`Discard "${task.title}"?`, "Discard", true, async () => {
await post("/api/tasks/{taskId}/discard", { path: { taskId: task.id } });
location.hash = "#/";
});
replace(head, h("div", { class: "head-actions session-actions" }, compactButton(sess?.id, sess?.state, sess && context.get(sess.id)), mergeIn), h("div", { class: "head-actions" }, merge, discard));
replace(head, ...headImpl.render((id) => context.get(id)));
};
let composerModel = "";
let focusStart = false;
const renderComposer = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
if (!task) {
const key = src.key();
if (!key) {
replace(composer);
return;
}
const sess = currentSession(task.id);
const sess = src.session();
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);
const form = composeForm(src, key, live?.id, live?.state ?? "idle", composerModel, showPending);
const old = composer.querySelector("textarea");
const caret = old !== null && document.activeElement === old ? [old.selectionStart, old.selectionEnd] : undefined;
replace(composer, form);
@@ -290,7 +335,7 @@ export function mountTranscript(root) {
}
stream.timer = 0;
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
replace(stream.el, markdown(stream.text, "text md", { taskId: state.selectedTaskId ?? undefined }));
replace(stream.el, markdown(stream.text, "text md", { taskId: ctx.taskId }));
if (pinned) {
transcript.scrollTop = transcript.scrollHeight;
}
@@ -312,12 +357,9 @@ export function mountTranscript(root) {
const appendEvents = (events) => {
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
let contextChanged = false;
headImpl.onEvents?.(events);
for (const ev of events) {
const p = ev.payload;
if (ev.kind === "agent.tool_result" || ev.kind === "turn.ended") {
clearTimeout(unmergedTimer);
unmergedTimer = setTimeout(() => void loadUnmerged(), 1500);
}
if (ev.kind === "agent.message" || ev.kind === "turn.ended" || ev.kind === "user.message") {
dropStream();
}
@@ -365,7 +407,7 @@ export function mountTranscript(root) {
}
if (contextChanged) {
renderHead();
const sess = state.selectedTaskId ? currentSession(state.selectedTaskId) : undefined;
const sess = src.session();
if (sess && !sess.model && (context.get(sess.id)?.model ?? "") !== composerModel) {
renderComposer();
}
@@ -382,32 +424,25 @@ export function mountTranscript(root) {
ctx = newCtx();
context = new Map();
focusStart = true;
clearTimeout(unmergedTimer);
void loadUnmerged();
headImpl.reset?.();
renderHead();
if (!state.selectedTaskId) {
if (!src.key()) {
renderComposer();
replace(transcript, h("div", { class: "empty" }, "No task selected. Pick one with ⌘K or from the Tasks tab."));
replace(transcript, h("div", { class: "empty" }, src.empty));
return;
}
replace(transcript, spinner);
appendEvents(state.transcript);
appendEvents(src.events());
renderComposer();
};
const offs = [
onEach(["selection"], renderAll),
onEach(["tasks"], () => {
onEach(src.resetTopics, renderAll),
onEach(src.refreshTopics, () => {
renderHead();
renderComposer();
}),
onEach(["sessions"], (taskId) => {
if (taskId === state.selectedTaskId) {
renderHead();
renderComposer();
}
}),
onEach(["transcript"], (fresh) => appendEvents(fresh)),
onEach(["stream"], (ev) => appendDelta(ev)),
onEach([src.transcriptTopic], (fresh) => appendEvents(fresh)),
onEach([src.streamTopic], (ev) => appendDelta(ev)),
onEach(["inbox"], ackVisible),
];
document.addEventListener("visibilitychange", ackVisible);
@@ -418,7 +453,7 @@ export function mountTranscript(root) {
off();
}
document.removeEventListener("visibilitychange", ackVisible);
clearTimeout(unmergedTimer);
headImpl.dispose?.();
observer.disconnect();
dropStream();
};
@@ -549,13 +584,10 @@ function modelSelect(key, current) {
};
return select;
}
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,
});
const clearDraft = keepDraft(text, `message:${taskId}`);
const send = h("button", { type: "submit", disabled: sessionState !== "idle" }, "Send");
function composeForm(src, key, sessionId, sessionState, currentModel, showPending) {
const text = h("textarea", { placeholder: src.placeholder(sessionState), rows: 3 });
const clearDraft = keepDraft(text, `message:${key}`);
const send = h("button", { type: "submit", disabled: !src.canSend(sessionState) }, "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) {
@@ -568,15 +600,15 @@ function composeForm(taskId, sessionId, sessionState, currentModel, showPending)
void showAlert(errorText(err));
}
};
const modelKey = `message:${sessionId ?? taskId}`;
const modelKey = `message:${sessionId ?? key}`;
const model = modelSelect(modelKey, currentModel);
const form = h("form", { class: "compose" });
const tray = imageTray(form, text, `message:${taskId}`);
const tray = imageTray(form, text, `message:${key}`);
form.append(tray.el, text, h("div", { class: "compose-actions" }, model, interrupt, send));
form.onsubmit = async (e) => {
e.preventDefault();
const value = text.value;
if (!value.trim() && !imageDrafts.has(`message:${taskId}`)) {
if (!value.trim() && !imageDrafts.has(`message:${key}`)) {
return;
}
const imgs = tray.take();
@@ -585,20 +617,12 @@ function composeForm(taskId, sessionId, sessionState, currentModel, showPending)
const cancel = showPending(value, imgs);
try {
const images = await uploadImages(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 } });
}
await src.send({ key, sessionId, text: value, images, model: model.value !== currentModel ? model.value : undefined, firstModel: model.value });
modelChoices.delete(modelKey);
}
catch (err) {
cancel();
composerDrafts.set(`message:${taskId}`, value);
composerDrafts.set(`message:${key}`, value);
text.value = value;
tray.restore(imgs);
void showAlert(errorText(err));
@@ -615,7 +639,7 @@ function submitOnEnter(input, form) {
}
};
}
function compactButton(sessionId, sessionState, c) {
export function compactButton(sessionId, sessionState, c) {
const pct = c && c.window > 0 ? Math.min(100, (c.used / c.window) * 100) : 0;
const level = pct >= 90 ? "danger" : pct >= 70 ? "warn" : "ok";
const usage = c && c.window > 0 ? `\ncontext: ${c.used.toLocaleString()} of ${c.window.toLocaleString()} tokens${c.model ? ` (${c.model})` : ""}` : "";
@@ -657,7 +681,7 @@ function snapMark(ns) {
e.preventDefault();
e.stopPropagation();
pinSnapshot(hi);
openChanges(b);
focusTab("changes");
};
return b;
}