Add a per-user lead agent and make the right pane task-focused
This commit is contained in:
Vendored
+154
-130
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user