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
-1
View File
File diff suppressed because one or more lines are too long
+6 -3
View File
@@ -52,7 +52,7 @@ export const put = (path, opts) => request("put", path, opts);
export const del = (path, opts) => request("delete", path, opts);
const staleAfterMs = 40_000;
const retryAfterMs = 2_000;
export function subscribe(after, taskId, onEvent, onState) {
export function subscribe(after, filter, onEvent, onState) {
let last = after;
let heard = Date.now();
let source;
@@ -62,8 +62,11 @@ export function subscribe(after, taskId, onEvent, onState) {
source?.close();
heard = Date.now();
const query = new URLSearchParams({ after: String(last) });
if (taskId) {
query.set("task_id", taskId);
if (filter.task_id) {
query.set("task_id", filter.task_id);
}
if (filter.lead) {
query.set("lead", "true");
}
const s = new EventSource(`/api/events?${query}`);
source = s;
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
+16 -9
View File
@@ -1,8 +1,8 @@
import { get, put, subscribe } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { mountNavbar } from "./navbar.js";
import { Pane } from "./panes.js";
import { handleEvent, loadInbox, loadMe, loadProjects, notify, on, selectTask, state } from "./state.js";
import { leadPane, taskPane } from "./panes.js";
import { handleEvent, loadFocus, loadInbox, loadLeadSessions, loadMe, loadProjects, notify, on, selectTask, setFocus, startLeadStream, state } from "./state.js";
function identityBanner(root) {
const render = () => {
const me = state.me;
@@ -33,6 +33,9 @@ function identityBanner(root) {
function route() {
const match = /^#\/task\/(\w+)$/.exec(location.hash);
const taskId = match?.[1];
if (taskId !== state.focus.task_id) {
setFocus({ task_id: taskId ?? null }).catch((err) => console.error("set focus", err));
}
if (taskId !== state.selectedTaskId) {
selectTask(taskId);
}
@@ -40,10 +43,16 @@ function route() {
async function main() {
const navbar = h("header", { class: "navbar" });
const banner = h("div", { class: "banner", hidden: true });
const left = new Pane("left", "transcript");
const right = new Pane("right", "inbox");
replace(document.body, navbar, banner, h("div", { class: "panes" }, left.el, right.el));
mountNavbar(navbar, (view) => right.show(view));
const status = await get("/api/admin/status");
await Promise.all([loadMe(), loadProjects(), loadInbox(), loadLeadSessions()]);
const linked = location.hash;
await loadFocus();
if (/^#\/task\/\w+$/.test(linked)) {
location.hash = linked;
}
startLeadStream();
replace(document.body, navbar, banner, h("div", { class: "panes" }, leadPane(), taskPane()));
mountNavbar(navbar);
identityBanner(banner);
addEventListener("keydown", (e) => {
if (e.key !== "Escape") {
@@ -52,9 +61,7 @@ async function main() {
e.preventDefault();
document.querySelector("dialog[open]")?.close();
}, { capture: true });
const status = await get("/api/admin/status");
await Promise.all([loadMe(), loadProjects(), loadInbox()]);
subscribe(status.last_event_seq, undefined, handleEvent, (connected) => {
subscribe(status.last_event_seq, {}, handleEvent, (connected) => {
state.connected = connected;
notify("connection");
});
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
+21
View File
@@ -55,6 +55,27 @@ function modal(text, buttons) {
actions.at(-1)?.focus();
});
}
export function openPanel(title, mount) {
if (document.querySelector("dialog.panel[open]")) {
return;
}
const close = h("button", { type: "button", class: "panel-close", title: "Close (Esc)" }, "×");
const body = h("div", { class: "panel-body" });
const dialog = h("dialog", { class: "modal panel" }, h("header", { class: "panel-head" }, h("h2", {}, title), close), body);
close.onclick = () => dialog.close();
dialog.onclick = (e) => {
if (e.target === dialog || e.target.closest("a[href^='#']")) {
dialog.close();
}
};
document.body.append(dialog);
const dispose = mount(body);
dialog.onclose = () => {
dispose();
dialog.remove();
};
dialog.showModal();
}
export async function showAlert(text) {
await modal(text, [{ label: "OK", value: "ok", class: "primary" }]);
}
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"dom.js","sourceRoot":"","sources":["../src/dom.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"dom.js","sourceRoot":"","sources":["../src/dom.ts"],"names":[],"mappings":"AAQA,MAAM,UAAU,CAAC,CAAwC,GAAM,EAAE,QAAkB,EAAE,EAAE,GAAG,QAAiB;IACzG,MAAM,EAAE,GAAG,QAAQ,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC;IACvC,MAAM,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,IAAI,EAAE,GAAG,KAAK,CAAC;IAEzD,IAAI,SAAS,EAAE,CAAC;QACd,EAAE,CAAC,SAAS,GAAG,SAAS,CAAC;IAC3B,CAAC;IACD,IAAI,KAAK,EAAE,CAAC;QACV,EAAE,CAAC,YAAY,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IAClC,CAAC;IACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI,EAAE,CAAC,EAAE,CAAC;QACtD,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IAC1B,CAAC;IACD,MAAM,CAAC,MAAM,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC;IAExB,MAAM,CAAC,EAAE,EAAE,GAAG,QAAQ,CAAC,CAAC;IACxB,OAAO,EAAE,CAAC;AACZ,CAAC;AAED,MAAM,UAAU,MAAM,CAAC,EAAQ,EAAE,GAAG,QAAiB;IACnD,KAAK,MAAM,KAAK,IAAI,QAAQ,EAAE,CAAC;QAC7B,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,KAAK,EAAE,CAAC;YAC7D,SAAS;QACX,CAAC;QACD,EAAE,CAAC,WAAW,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,cAAc,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC7F,CAAC;AACH,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,EAAW,EAAE,GAAG,QAAiB;IACvD,EAAE,CAAC,eAAe,EAAE,CAAC;IACrB,MAAM,CAAC,EAAE,EAAE,GAAG,QAAQ,CAAC,CAAC;AAC1B,CAAC;AAED,MAAM,UAAU,GAAG,CAAC,GAAW;IAC7B,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAClE,IAAI,OAAO,GAAG,EAAE,EAAE,CAAC;QACjB,OAAO,UAAU,CAAC;IACpB,CAAC;IACD,IAAI,OAAO,GAAG,IAAI,EAAE,CAAC;QACnB,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,EAAE,CAAC,OAAO,CAAC;IAC5C,CAAC;IACD,IAAI,OAAO,GAAG,KAAK,EAAE,CAAC;QACpB,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC;IAC9C,CAAC;IACD,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC;AAC/C,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,GAAY;IACpC,OAAO,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;AAC1D,CAAC;AAED,SAAS,KAAK,CAAC,IAAY,EAAE,OAA2D;IACtF,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC;IACnH,MAAM,MAAM,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,EAAE,IAAI,CAAC,EAAE,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,eAAe,EAAE,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACzK,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC7B,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,MAAM,CAAC,OAAO,GAAG,GAAG,EAAE;YACpB,MAAM,CAAC,MAAM,EAAE,CAAC;YAChB,OAAO,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;QAC9B,CAAC,CAAC;QACF,MAAM,CAAC,SAAS,EAAE,CAAC;QACnB,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC;IAC1B,CAAC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,KAAa,EAAE,KAAwC;IAC/E,IAAI,QAAQ,CAAC,aAAa,CAAC,oBAAoB,CAAC,EAAE,CAAC;QACjD,OAAO;IACT,CAAC;IACD,MAAM,KAAK,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,aAAa,EAAE,KAAK,EAAE,aAAa,EAAE,EAAE,GAAG,CAAC,CAAC;IAC/F,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,CAAC,CAAC;IAC/C,MAAM,MAAM,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,aAAa,EAAE,EAAE,CAAC,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,EAAE,EAAE,KAAK,CAAC,EAAE,KAAK,CAAC,EAAE,IAAI,CAAC,CAAC;IAC5H,KAAK,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;IACrC,MAAM,CAAC,OAAO,GAAG,CAAC,CAAC,EAAE,EAAE;QACrB,IAAI,CAAC,CAAC,MAAM,KAAK,MAAM,IAAK,CAAC,CAAC,MAAkB,CAAC,OAAO,CAAC,cAAc,CAAC,EAAE,CAAC;YACzE,MAAM,CAAC,KAAK,EAAE,CAAC;QACjB,CAAC;IACH,CAAC,CAAC;IACF,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC7B,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC;IAC5B,MAAM,CAAC,OAAO,GAAG,GAAG,EAAE;QACpB,OAAO,EAAE,CAAC;QACV,MAAM,CAAC,MAAM,EAAE,CAAC;IAClB,CAAC,CAAC;IACF,MAAM,CAAC,SAAS,EAAE,CAAC;AACrB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,IAAY;IAC1C,MAAM,KAAK,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC;AACtE,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,IAAY,EAAE,MAAc,EAAE,MAAM,GAAG,KAAK;IAC5E,MAAM,KAAK,GAAG,MAAM,KAAK,CAAC,IAAI,EAAE;QAC9B,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE;QACpC,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC,SAAS,EAAE;KAC7E,CAAC,CAAC;IACH,OAAO,KAAK,KAAK,IAAI,CAAC;AACxB,CAAC"}
+11 -71
View File
@@ -3,44 +3,17 @@ import { mountChanges } from "./changes.js";
import { ago, errorText, h, replace, showAlert } from "./dom.js";
import { image, inlineMarkdown, markdown } from "./markdown.js";
import { diagram } from "./mermaid.js";
import { findTask, onEach, state } from "./state.js";
import { onEach, state } from "./state.js";
const checked = new Map();
const texts = new Map();
let scope = "all";
export function mountInbox(root) {
const tabs = h("nav", { class: "scope-tabs" });
const list = h("div", { class: "inbox-list" });
const cards = new Map();
const groups = new Map();
const empty = h("div", { class: "empty" }, "Nothing waiting on you.");
const visible = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
if (scope === "task" && task) {
return state.inbox.filter((i) => i.task_id === task.id);
}
if (scope === "project" && task) {
return state.inbox.filter((i) => i.project_id === task.project_id);
}
return state.inbox;
};
const renderTabs = () => {
const hasTask = state.selectedTaskId !== undefined;
const tab = (s, label) => {
const b = h("button", { class: s === scope ? "active" : "", disabled: s !== "all" && !hasTask }, label);
b.onclick = () => {
scope = s;
render();
};
return b;
};
replace(tabs, tab("all", "All"), tab("project", "This project"), tab("task", "This task"));
};
const nothing = h("div", { class: "empty" }, "Nothing waiting on you in this task.");
const noTask = h("div", { class: "empty" }, "No task selected.");
const render = () => {
if (scope !== "all" && !state.selectedTaskId) {
scope = "all";
}
renderTabs();
const items = visible();
const taskId = state.selectedTaskId;
const items = state.inbox.filter((i) => i.task_id === taskId).sort((a, b) => a.created_at.localeCompare(b.created_at));
const keep = new Set(items.map((i) => i.id));
for (const id of cards.keys()) {
if (!keep.has(id)) {
@@ -64,32 +37,15 @@ export function mountInbox(root) {
cards.set(item.id, { sig, el });
return el;
};
const shown = groupByTask(items);
const keepGroups = new Set(shown.map((g) => g.taskId));
for (const id of groups.keys()) {
if (!keepGroups.has(id)) {
groups.delete(id);
}
if (!taskId) {
reconcile(list, [noTask]);
return;
}
const els = shown.map((g) => {
const projectName = state.projects.find((p) => p.id === g.projectId)?.name ?? g.projectId;
const title = findTask(g.taskId)?.title ?? g.taskId;
const head = JSON.stringify([projectName, title]);
let group = groups.get(g.taskId);
if (!group || group.head !== head) {
const h3 = h("h3", {}, h("span", { class: "project-name" }, projectName, " › "), h("a", { href: `#/task/${g.taskId}` }, title));
group = { head, h3, el: group?.el ?? h("section", {}) };
groups.set(g.taskId, group);
}
group.el.className = `task-group ${g.blocked ? "blocked" : ""}`;
reconcile(group.el, [group.h3, ...g.items.map(cardFor)]);
return group.el;
});
reconcile(list, els.length > 0 ? els : [empty]);
reconcile(list, items.length > 0 ? items.map(cardFor) : [nothing]);
};
const off = onEach(["inbox", "selection", "tasks"], render);
const off = onEach(["inbox", "selection"], render);
const changes = h("section", { class: "inbox-changes" });
replace(root, h("div", { class: "inbox-view" }, changes, tabs, list));
replace(root, h("div", { class: "inbox-view" }, changes, list));
const offChanges = mountChanges(changes, true);
render();
return () => {
@@ -108,22 +64,6 @@ function reconcile(parent, nodes) {
parent.lastChild.remove();
}
}
function groupByTask(items) {
const groups = new Map();
for (const item of [...items].sort((a, b) => a.created_at.localeCompare(b.created_at))) {
let g = groups.get(item.task_id);
if (!g) {
g = { taskId: item.task_id, projectId: item.project_id, blocked: false, items: [] };
groups.set(item.task_id, g);
}
g.items.push(item);
g.blocked ||= item.priority === "blocked";
}
const projectName = (id) => state.projects.find((p) => p.id === id)?.name ?? id;
return [...groups.values()].sort((a, b) => Number(b.blocked) - Number(a.blocked) ||
projectName(a.projectId).localeCompare(projectName(b.projectId)) ||
a.items[0].created_at.localeCompare(b.items[0].created_at));
}
function clickable(el, go) {
el.onclick = (e) => {
if (!e.target.closest("a")) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+34
View File
@@ -0,0 +1,34 @@
import { post } from "./api.js";
import { h } from "./dom.js";
import { currentLead, state } from "./state.js";
import { compactButton, mountChat } from "./task.js";
const leadSource = {
key: () => "lead",
events: () => state.leadTranscript,
session: currentLead,
root: () => "",
taskId: () => undefined,
resetTopics: [],
refreshTopics: ["lead.sessions"],
transcriptTopic: "lead.transcript",
streamTopic: "lead.stream",
empty: "",
canSend: () => true,
placeholder: (s) => (s === "running" ? "Message your lead; it gets this when its turn ends" : "Message your lead. Paste or drop images to attach them."),
send: async ({ text, images, model, firstModel, sessionId }) => {
await post("/api/me/lead/messages", { body: { text, images, model: sessionId ? model : firstModel } });
},
};
function leadHead() {
return {
render: (usage) => {
const sess = currentLead();
const live = sess && sess.state !== "completed" ? sess : undefined;
return [h("div", { class: "head-actions session-actions" }, compactButton(live?.id, live?.state, live && usage(live.id)))];
},
};
}
export function mountLead(root) {
return mountChat(root, leadSource, leadHead);
}
//# sourceMappingURL=lead.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"lead.js","sourceRoot":"","sources":["../src/lead.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AAChC,OAAO,EAAE,CAAC,EAAE,MAAM,UAAU,CAAC;AAC7B,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,EAAE,aAAa,EAAE,SAAS,EAA8B,MAAM,WAAW,CAAC;AAEjF,MAAM,UAAU,GAAe;IAC7B,GAAG,EAAE,GAAG,EAAE,CAAC,MAAM;IACjB,MAAM,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,cAAc;IAClC,OAAO,EAAE,WAAW;IACpB,IAAI,EAAE,GAAG,EAAE,CAAC,EAAE;IACd,MAAM,EAAE,GAAG,EAAE,CAAC,SAAS;IACvB,WAAW,EAAE,EAAE;IACf,aAAa,EAAE,CAAC,eAAe,CAAC;IAChC,eAAe,EAAE,iBAAiB;IAClC,WAAW,EAAE,aAAa;IAC1B,KAAK,EAAE,EAAE;IACT,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI;IACnB,WAAW,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,oDAAoD,CAAC,CAAC,CAAC,yDAAyD,CAAC;IACxJ,IAAI,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,EAAE,EAAE;QAC7D,MAAM,IAAI,CAAC,uBAAuB,EAAE,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IACzG,CAAC;CACF,CAAC;AAEF,SAAS,QAAQ;IACf,OAAO;QACL,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE;YAChB,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;YAC3B,MAAM,IAAI,GAAG,IAAI,IAAI,IAAI,CAAC,KAAK,KAAK,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;YACnE,OAAO,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,8BAA8B,EAAE,EAAE,aAAa,CAAC,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,IAAI,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAC7H,CAAC;KACF,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,IAAiB;IACzC,OAAO,SAAS,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,CAAC,CAAC;AAC/C,CAAC"}
+11 -14
View File
@@ -1,14 +1,15 @@
import { post } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { openTranscript } from "./panes.js";
import { activeTasks, currentSession, findTask, lastVisits, onEach, state } from "./state.js";
export function mountNavbar(root, show) {
import { openSettings } from "./settings.js";
import { activeTasks, currentSession, findTask, focusTask, lastVisits, onEach, state } from "./state.js";
import { openStatus } from "./status.js";
export function mountNavbar(root) {
const crumb = h("button", { class: "crumb", title: "Switch task (⌘K)" });
crumb.onclick = openSwitcher;
const inbox = h("button", { class: "inbox-count" });
inbox.onclick = () => show("inbox");
const admin = h("button", { class: "nav-link" }, "Admin");
admin.onclick = () => show("admin");
const status = h("button", { class: "nav-link" }, "Status");
status.onclick = openStatus;
const settings = h("button", { class: "nav-link" }, "Settings");
settings.onclick = openSettings;
const conn = h("span", { class: "connection down", title: "connecting" });
const connLabel = h("span", { class: "connection-label" }, "reconnecting…");
const me = h("span", { class: "me" });
@@ -16,16 +17,13 @@ export function mountNavbar(root, show) {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
const project = task ? state.projects.find((p) => p.id === task.project_id) : undefined;
replace(crumb, task ? `${project?.name ?? ""} ▸ ${task.title}` : "No task", " ▾");
const blocked = state.inbox.filter((i) => i.priority === "blocked").length;
inbox.className = blocked > 0 ? "inbox-count urgent" : "inbox-count";
replace(inbox, `Inbox ${state.inbox.length}`, blocked > 0 && h("span", { class: "blocked-count" }, ` · ${blocked} blocked`));
conn.className = state.connected ? "connection up" : "connection down";
conn.title = state.connected ? "live" : "reconnecting";
connLabel.hidden = state.connected;
replace(me, state.me?.display_name ?? "");
};
onEach(["me", "projects", "tasks", "inbox", "selection", "connection"], render);
replace(root, h("a", { href: "#/", class: "brand" }, "byre"), crumb, h("span", { class: "spacer" }), inbox, admin, connLabel, conn, me);
onEach(["me", "projects", "tasks", "selection", "connection"], render);
replace(root, h("a", { href: "#/", class: "brand" }, "byre"), crumb, h("span", { class: "spacer" }), status, settings, connLabel, conn, me);
render();
addEventListener("keydown", (e) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey) && !document.querySelector("dialog[open]")) {
@@ -62,8 +60,7 @@ function openSwitcher() {
};
const go = (taskId) => {
close();
location.hash = `#/task/${taskId}`;
openTranscript();
focusTask(taskId);
};
const create = async (projectId, title) => {
close();
+1 -1
View File
File diff suppressed because one or more lines are too long
+31 -54
View File
@@ -1,71 +1,48 @@
import { mountAdmin } from "./admin.js";
import { mountChanges } from "./changes.js";
import { h, replace } from "./dom.js";
import { mountFiles } from "./files.js";
import { mountInbox } from "./inbox.js";
import { mountSettings } from "./settings.js";
import { mountLead } from "./lead.js";
import { focusTab, on, state, tabs } from "./state.js";
import { mountTranscript } from "./task.js";
import { mountTasks } from "./tasks.js";
import { mountTodo } from "./todo.js";
const views = {
transcript: { label: "Transcript", mount: mountTranscript },
changes: { label: "Changes", mount: (root) => mountChanges(root) },
files: { label: "Files", mount: mountFiles },
tasks: { label: "Tasks", mount: mountTasks },
todo: { label: "Todo", mount: mountTodo },
inbox: { label: "Inbox", mount: mountInbox },
admin: { label: "Admin", mount: mountAdmin },
settings: { label: "Settings", mount: mountSettings },
todo: { label: "Todo", mount: mountTodo },
};
const order = ["transcript", "changes", "files", "tasks", "todo", "inbox", "admin", "settings"];
const panes = [];
export function openTranscript() {
if (panes.some((p) => p.current() === "transcript")) {
return;
}
(panes.find((p) => p.current() !== "tasks") ?? panes[0])?.show("transcript");
export function leadPane() {
const root = h("div", { class: "view view-lead" });
mountLead(root);
return h("section", { class: "pane" }, h("nav", { class: "pane-tabs" }, h("button", { class: "active" }, "Lead")), h("div", { class: "pane-body" }, root));
}
export function openChanges(from) {
if (panes.some((p) => p.current() === "changes")) {
return;
}
(panes.find((p) => !p.el.contains(from)) ?? panes[0])?.show("changes");
}
export class Pane {
key;
el;
tabs;
body;
view;
dispose = () => { };
constructor(key, fallback) {
this.key = key;
this.tabs = h("nav", { class: "pane-tabs" });
this.body = h("div", { class: "pane-body" });
this.el = h("section", { class: "pane" }, this.tabs, this.body);
const saved = localStorage.getItem(this.storageKey());
this.view = saved && saved in views ? saved : fallback;
this.show(this.view);
panes.push(this);
}
current() {
return this.view;
}
show(view) {
this.dispose();
this.view = view;
localStorage.setItem(this.storageKey(), view);
replace(this.tabs, ...order.map((v) => {
const b = h("button", { class: v === view ? "active" : "" }, views[v].label);
b.onclick = () => this.show(v);
export function taskPane() {
const strip = h("nav", { class: "pane-tabs" });
const body = h("div", { class: "pane-body" });
let shown;
let dispose = () => { };
const render = () => {
const first = state.focus.tabs.filter((t, i, all) => tabs.includes(t) && all.indexOf(t) === i);
const order = [...first, ...tabs.filter((t) => !first.includes(t))];
const tab = state.focus.tab;
replace(strip, ...order.map((t) => {
const b = h("button", { class: t === tab ? "active" : "" }, views[t].label);
b.onclick = () => focusTab(t);
return b;
}));
const root = h("div", { class: `view view-${view}` });
replace(this.body, root);
this.dispose = views[view].mount(root);
}
storageKey() {
return `byre.pane.${this.key}`;
}
if (tab === shown) {
return;
}
dispose();
shown = tab;
const root = h("div", { class: `view view-${tab}` });
replace(body, root);
dispose = views[tab].mount(root);
};
on("focus", render);
render();
return h("section", { class: "pane" }, strip, body);
}
//# sourceMappingURL=panes.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"panes.js","sourceRoot":"","sources":["../src/panes.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"panes.js","sourceRoot":"","sources":["../src/panes.ts"],"names":[],"mappings":"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"}
+5 -2
View File
@@ -1,5 +1,5 @@
import { del, get, post, put } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { errorText, h, openPanel, replace, showAlert } from "./dom.js";
import { on, state } from "./state.js";
const kinds = [
{ key: "blocking_questions", label: "Blocking questions" },
@@ -8,7 +8,10 @@ const kinds = [
{ key: "errors", label: "Session errors" },
{ key: "rate_limits", label: "Rate limits" },
];
export function mountSettings(root) {
export function openSettings() {
openPanel("Settings", mountSettings);
}
function mountSettings(root) {
const device = h("div", { class: "push-device" });
const prefs = h("fieldset", { class: "push-prefs" });
const renderPrefs = () => {
+1 -1
View File
File diff suppressed because one or more lines are too long
+111 -41
View File
@@ -1,4 +1,5 @@
import { get, subscribe } from "./api.js";
import { get, put, subscribe } from "./api.js";
export const tabs = ["transcript", "changes", "files", "inbox", "todo"];
export const state = {
me: undefined,
projects: [],
@@ -7,9 +8,11 @@ export const state = {
inbox: [],
selectedTaskId: undefined,
transcript: [],
lastTranscriptSeq: 0,
pinned: undefined,
connected: false,
focus: { tabs: ["transcript"], tab: "transcript" },
leadSessions: [],
leadTranscript: [],
};
const listeners = new Map();
export function on(topic, fn) {
@@ -49,6 +52,9 @@ export function findTask(id) {
export function currentSession(taskId) {
return state.sessions.get(taskId)?.findLast((s) => s.state !== "completed") ?? state.sessions.get(taskId)?.at(-1);
}
export function currentLead() {
return state.leadSessions.findLast((s) => s.state !== "completed") ?? state.leadSessions.at(-1);
}
export function snapshots() {
return state.transcript.filter((e) => e.kind === "snapshot.created").map((e) => e.payload);
}
@@ -79,9 +85,96 @@ export async function loadInbox() {
state.inbox = await get("/api/inbox");
notify("inbox");
}
export async function loadLeadSessions() {
state.leadSessions = await get("/api/me/lead/sessions");
notify("lead.sessions");
}
export async function loadFocus() {
applyFocus(await get("/api/me/focus"));
}
export function applyFocus(f) {
state.focus = f;
const hash = f.task_id ? `#/task/${f.task_id}` : "#/";
if (location.hash !== hash && !(hash === "#/" && location.hash === "")) {
location.hash = hash;
}
notify("focus");
}
export async function setFocus(change) {
const taskId = change.task_id === undefined ? state.focus.task_id : (change.task_id ?? undefined);
const next = { tabs: state.focus.tabs, tab: change.tab ?? state.focus.tab };
if (taskId) {
next.task_id = taskId;
}
applyFocus(next);
applyFocus(await put("/api/me/focus", { body: next }));
}
export function focusTask(taskId, tab = "transcript") {
setFocus({ task_id: taskId, tab }).catch((err) => console.error("set focus", err));
}
export function focusTab(tab) {
setFocus({ tab }).catch((err) => console.error("set focus", err));
}
class Feed {
transcriptTopic;
streamTopic;
store;
events = [];
last = 0;
buffer = [];
timer = 0;
constructor(transcriptTopic, streamTopic, store) {
this.transcriptTopic = transcriptTopic;
this.streamTopic = streamTopic;
this.store = store;
}
reset() {
clearTimeout(this.timer);
this.timer = 0;
this.buffer = [];
this.events = [];
this.last = 0;
this.store(this.events);
}
push = (ev) => {
this.buffer.push(ev);
if (!this.timer) {
this.timer = setTimeout(() => this.flush(), 0);
}
};
flush() {
const events = this.buffer;
this.buffer = [];
this.timer = 0;
let stored = [];
for (const ev of events) {
if (ev.seq !== 0) {
stored.push(ev);
continue;
}
this.append(stored);
stored = [];
notify(this.streamTopic, ev);
}
this.append(stored);
}
append(events) {
const fresh = events.filter((ev) => ev.seq > this.last);
if (fresh.length === 0) {
return;
}
this.events.push(...fresh);
this.last = fresh.at(-1).seq;
notify(this.transcriptTopic, fresh);
}
}
const taskFeed = new Feed("transcript", "stream", (events) => {
state.transcript = events;
});
const leadFeed = new Feed("lead.transcript", "lead.stream", (events) => {
state.leadTranscript = events;
});
let closeTaskStream;
let taskBuffer = [];
let taskFlush = 0;
const visitsKey = "byre.visits";
export function lastVisits() {
return JSON.parse(localStorage.getItem(visitsKey) ?? "{}");
@@ -97,49 +190,18 @@ export function selectTask(taskId) {
markVisited(taskId);
closeTaskStream?.();
closeTaskStream = undefined;
clearTimeout(taskFlush);
taskFlush = 0;
taskBuffer = [];
taskFeed.reset();
state.selectedTaskId = taskId;
state.transcript = [];
state.lastTranscriptSeq = 0;
state.pinned = undefined;
notify("selection");
if (!taskId) {
return;
}
closeTaskStream = subscribe(0, taskId, queueTaskEvent, () => undefined);
closeTaskStream = subscribe(0, { task_id: taskId }, taskFeed.push, () => undefined);
}
function queueTaskEvent(ev) {
taskBuffer.push(ev);
if (!taskFlush) {
taskFlush = setTimeout(flushTaskEvents, 0);
}
}
function flushTaskEvents() {
const events = taskBuffer;
taskBuffer = [];
taskFlush = 0;
let stored = [];
for (const ev of events) {
if (ev.seq !== 0) {
stored.push(ev);
continue;
}
appendTranscript(stored);
stored = [];
notify("stream", ev);
}
appendTranscript(stored);
}
function appendTranscript(events) {
const fresh = events.filter((ev) => ev.seq > state.lastTranscriptSeq);
if (fresh.length === 0) {
return;
}
state.transcript.push(...fresh);
state.lastTranscriptSeq = fresh.at(-1).seq;
notify("transcript", fresh);
export function startLeadStream() {
leadFeed.reset();
subscribe(0, { lead: true }, leadFeed.push, () => undefined);
}
export function handleEvent(ev) {
if (ev.seq === 0) {
@@ -166,7 +228,10 @@ export function handleEvent(ev) {
case "session.created":
case "session.state":
case "session.model":
if (ev.task_id) {
if (ev.owner) {
void loadLeadSessions();
}
else if (ev.task_id) {
void loadSessions(ev.task_id);
}
break;
@@ -174,6 +239,11 @@ export function handleEvent(ev) {
case "inbox.updated":
void loadInbox();
break;
case "focus.updated":
if (ev.actor !== state.me?.login) {
applyFocus(ev.payload);
}
break;
}
notify("event", ev);
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+14 -11
View File
@@ -1,8 +1,11 @@
import { get } from "./api.js";
import { ago, errorText, h, replace } from "./dom.js";
import { ago, errorText, h, openPanel, replace } from "./dom.js";
import { findTask, on, onEach, state } from "./state.js";
const refreshKinds = new Set(["session.created", "session.state", "task.created", "task.merged", "task.discarded", "project.created", "admin.health"]);
export function mountAdmin(root) {
export function openStatus() {
openPanel("Status", mountStatus);
}
function mountStatus(root) {
let status;
let error = "";
let inFlight = false;
@@ -13,10 +16,10 @@ export function mountAdmin(root) {
replace(root, h("div", { class: error ? "empty error-text" : "empty" }, error || "Loading…"));
return;
}
const scrollTop = root.querySelector(".admin-view")?.scrollTop ?? 0;
const scrollTop = root.querySelector(".status-view")?.scrollTop ?? 0;
const open = new Set([...root.querySelectorAll(".problems details[open]")].map((d) => d.dataset["vendor"] ?? ""));
replace(root, h("div", { class: "admin-view" }, error && h("p", { class: "error-text" }, error), problemsSection(status, open), turnsSection(status), rateLimitsSection(status.rate_limits), serverSection(status)));
root.querySelector(".admin-view").scrollTop = scrollTop;
replace(root, h("div", { class: "status-view" }, error && h("p", { class: "error-text" }, error), problemsSection(status, open), turnsSection(status), rateLimitsSection(status.rate_limits), serverSection(status)));
root.querySelector(".status-view").scrollTop = scrollTop;
};
const refresh = async () => {
if (inFlight) {
@@ -86,7 +89,7 @@ export function mountAdmin(root) {
function turnsSection(s) {
const vendors = [...new Set([...Object.keys(s.caps.per_vendor), ...s.running_turns.map((t) => t.vendor), ...s.queued_turns.map((t) => t.vendor)])].sort();
const runningFor = (vendor) => s.running_turns.filter((t) => t.vendor === vendor).length;
return h("section", { class: "admin-section" }, h("h2", {}, "Turns"), h("div", { class: "caps" }, capTile("All vendors", s.running_turns.length, s.caps.global), ...vendors.map((v) => capTile(v, runningFor(v), s.caps.per_vendor[v])), h("div", { class: "cap-tile" }, h("div", { class: "cap-label" }, "Queued"), h("div", { class: s.queued_turns.length > 0 ? "cap-value warn" : "cap-value" }, s.queued_turns.length))), h("h3", {}, "Running"), turnList(s.running_turns, "running", "No turns running.", s.agent_processes), h("h3", {}, "Queued"), turnList(s.queued_turns, "queued", "Nothing queued.", []));
return h("section", { class: "status-section" }, h("h2", {}, "Turns"), h("div", { class: "caps" }, capTile("All vendors", s.running_turns.length, s.caps.global), ...vendors.map((v) => capTile(v, runningFor(v), s.caps.per_vendor[v])), h("div", { class: "cap-tile" }, h("div", { class: "cap-label" }, "Queued"), h("div", { class: s.queued_turns.length > 0 ? "cap-value warn" : "cap-value" }, s.queued_turns.length))), h("h3", {}, "Running"), turnList(s.running_turns, "running", "No turns running.", s.agent_processes), h("h3", {}, "Queued"), turnList(s.queued_turns, "queued", "Nothing queued.", []));
}
function capTile(label, running, cap) {
const full = cap !== undefined && running >= cap;
@@ -101,7 +104,7 @@ function turnList(turns, kind, empty, procs) {
}
function taskLink(taskId) {
if (!taskId) {
return "—";
return "lead";
}
const task = findTask(taskId);
const project = task ? state.projects.find((p) => p.id === task.project_id) : undefined;
@@ -136,7 +139,7 @@ function problemsSection(s, openVendors) {
if (s.zombies.length === 0 && vendors.length === 0) {
return false;
}
return h("section", { class: "admin-section" }, h("h2", {}, "Problems"), h("ul", { class: "problems" }, ...s.zombies.map((z) => h("li", {}, h("span", { class: "danger" }, zombieLabel[z.kind]), " ", z.task_id ? taskLink(z.task_id) : "", " ", h("span", { class: "hint" }, z.detail))), ...vendors.map((v) => {
return h("section", { class: "status-section" }, h("h2", {}, "Problems"), h("ul", { class: "problems" }, ...s.zombies.map((z) => h("li", {}, h("span", { class: "danger" }, zombieLabel[z.kind]), " ", z.task_id ? taskLink(z.task_id) : "", " ", h("span", { class: "hint" }, z.detail))), ...vendors.map((v) => {
const latest = pe.recent.find((e) => e.vendor === v);
const n = pe.counts[v] ?? 0;
return h("li", {}, h("span", { class: "danger" }, `${v}: ${n} bad stream line${n === 1 ? "" : "s"}`), latest &&
@@ -145,7 +148,7 @@ function problemsSection(s, openVendors) {
}
function rateLimitsSection(limits) {
const vendors = Object.keys(limits).sort();
return h("section", { class: "admin-section" }, h("h2", {}, "Rate limits"), ...vendors.map((v) => rateLimitCard(v, limits[v] ?? {})), vendors.length === 0 && h("p", { class: "hint" }, "No vendor has reported a rate-limit status since byre started."));
return h("section", { class: "status-section" }, h("h2", {}, "Rate limits"), ...vendors.map((v) => rateLimitCard(v, limits[v] ?? {})), vendors.length === 0 && h("p", { class: "hint" }, "No vendor has reported a rate-limit status since byre started."));
}
function rateLimitCard(vendor, info) {
const { status, rateLimitType, resetsAt, unifiedWindows, ...rest } = info;
@@ -188,7 +191,7 @@ function serverSection(s) {
["Database", bytes(s.db_bytes)],
["Last event", `#${s.last_event_seq}`],
];
return h("section", { class: "admin-section" }, h("h2", {}, "Server"), h("dl", { class: "kv" }, ...rows.flatMap(([k, v]) => [h("dt", {}, k), h("dd", {}, v)])));
return h("section", { class: "status-section" }, h("h2", {}, "Server"), h("dl", { class: "kv" }, ...rows.flatMap(([k, v]) => [h("dt", {}, k), h("dd", {}, v)])));
}
function bytes(n) {
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -200,4 +203,4 @@ function bytes(n) {
}
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
//# sourceMappingURL=admin.js.map
//# sourceMappingURL=status.js.map
+1
View File
File diff suppressed because one or more lines are too long
+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;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
-50
View File
@@ -1,50 +0,0 @@
import { post } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { openTranscript } from "./panes.js";
import { currentSession, onEach, state } from "./state.js";
export function mountTasks(root) {
const render = () => replace(root, h("div", { class: "tasks-view" }, ...state.projects.map(projectBlock), newProjectForm()));
const off = onEach(["projects", "tasks", "sessions", "inbox", "selection"], render);
render();
return off;
}
function projectBlock(project) {
const tasks = (state.tasks.get(project.id) ?? []).filter((t) => t.status === "active");
return h("section", { class: "project" }, h("h2", {}, project.name, " ", h("span", { class: "repo" }, project.repo_path)), h("ul", { class: "task-list" }, ...tasks.map((task) => {
const sess = currentSession(task.id);
const count = state.inbox.filter((item) => item.task_id === task.id).length;
return h("li", { class: task.id === state.selectedTaskId ? "selected" : "" }, h("a", { href: `#/task/${task.id}`, onclick: openTranscript }, h("span", { class: `dot ${sess?.state ?? "none"}`, title: sess?.state ?? "no session" }), h("span", { class: "task-title" }, task.title), h("span", { class: "task-state" }, sess?.state ?? "no agent"), count > 0 && h("span", { class: "badge" }, count)));
}), tasks.length === 0 && h("li", { class: "empty-row" }, "No active tasks.")), newTaskForm(project.id));
}
function newTaskForm(projectId) {
const title = h("input", { placeholder: "New task title", required: true });
const form = h("form", { class: "inline-form" }, title, h("button", { type: "submit" }, "Create"));
form.onsubmit = async (e) => {
e.preventDefault();
try {
const task = await post("/api/projects/{projectId}/tasks", { path: { projectId }, body: { title: title.value } });
location.hash = `#/task/${task.id}`;
}
catch (err) {
void showAlert(errorText(err));
}
};
return form;
}
function newProjectForm() {
const name = h("input", { placeholder: "Name", required: true });
const path = h("input", { placeholder: "Repo path on the server", required: true });
const form = h("form", { class: "stack-form" }, name, path, h("button", { type: "submit" }, "Add project"));
form.onsubmit = async (e) => {
e.preventDefault();
try {
await post("/api/projects", { body: { name: name.value, repo_path: path.value } });
form.reset();
}
catch (err) {
void showAlert(errorText(err));
}
};
return h("details", { class: "add-project", open: state.projects.length === 0 }, h("summary", {}, "Add project"), form);
}
//# sourceMappingURL=tasks.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"tasks.js","sourceRoot":"","sources":["../src/tasks.ts"],"names":[],"mappings":"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"}
+26 -8
View File
@@ -1,17 +1,21 @@
import { get, post } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { markdown } from "./markdown.js";
import { openTranscript } from "./panes.js";
import { models } from "./task.js";
import { currentSession, on, onEach, state } from "./state.js";
import { currentSession, findTask, focusTask, on, onEach, state } from "./state.js";
const refreshKinds = new Set(["task.merged", "project.created"]);
export function mountTodo(root) {
const todos = new Map();
const starting = new Set();
let disposed = false;
const project = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
return state.projects.find((p) => p.id === task?.project_id);
};
const render = () => {
const scrollTop = root.querySelector(".todo-view")?.scrollTop ?? 0;
replace(root, h("div", { class: "todo-view" }, ...state.projects.map((p) => projectBlock(p, todos.get(p.id), starting, start))));
const p = project();
replace(root, h("div", { class: "todo-view" }, p ? projectBlock(p, todos.get(p.id), starting, start) : h("div", { class: "empty" }, "No task selected.")));
root.querySelector(".todo-view").scrollTop = scrollTop;
};
const load = async (projectId) => {
@@ -27,7 +31,8 @@ export function mountTodo(root) {
}
};
const refresh = () => {
for (const p of state.projects) {
const p = project();
if (p) {
void load(p.id);
}
};
@@ -37,8 +42,7 @@ export function mountTodo(root) {
render();
try {
const task = await post("/api/projects/{projectId}/todo/tasks", { path: { projectId: project.id }, body: { title: item.title, model: models[0] } });
location.hash = `#/task/${task.id}`;
openTranscript();
focusTask(task.id);
}
catch (err) {
void showAlert(errorText(err));
@@ -64,7 +68,14 @@ export function mountTodo(root) {
}
}),
on("projects", refresh),
onEach(["tasks", "sessions", "selection"], render),
on("selection", () => {
render();
const p = project();
if (p && !todos.has(p.id)) {
void load(p.id);
}
}),
onEach(["tasks", "sessions"], render),
];
render();
refresh();
@@ -106,7 +117,14 @@ function projectBlock(project, todo, starting, start) {
let action;
if (task) {
const sess = currentSession(task.id);
action = h("a", { class: "todo-task", href: `#/task/${task.id}`, onclick: openTranscript }, h("span", { class: `dot ${sess?.state ?? "none"}`, title: sess?.state ?? "no session" }), sess?.state ?? "no agent");
action = h("a", {
class: "todo-task",
href: `#/task/${task.id}`,
onclick: (e) => {
e.preventDefault();
focusTask(task.id);
},
}, h("span", { class: `dot ${sess?.state ?? "none"}`, title: sess?.state ?? "no session" }), sess?.state ?? "no agent");
}
else {
const busy = starting.has(`${project.id}\n${item.title}`);
+1 -1
View File
File diff suppressed because one or more lines are too long
+159 -2
View File
@@ -54,6 +54,57 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/me/lead/sessions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description The calling user's lead agent sessions, oldest first. A compaction completes one and starts the next. */
get: operations["listLeadSessions"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/me/lead/messages": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** @description Sends a message to the calling user's lead agent, starting a lead session when there is none. While the lead is mid-turn the message is held and sent as its next turn. */
post: operations["messageLead"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/me/focus": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getFocus"];
/** @description Records the human switching the right pane's task or tab. The lead agent is told. */
put: operations["setFocus"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/push/key": {
parameters: {
query?: never;
@@ -595,7 +646,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** @description Server-sent events. Each event's id is its seq; data is the Event as JSON. Events after Last-Event-ID (or the after query parameter) are replayed before live events. With task_id, only that task's events are sent. */
/** @description Server-sent events. Each event's id is its seq; data is the Event as JSON. Events after Last-Event-ID (or the after query parameter) are replayed before live events. With task_id, only that task's events are sent; with lead=true, only the events of the caller's lead agent. Events with an owner go only to that user. */
get: operations["streamEvents"];
put?: never;
post?: never;
@@ -814,7 +865,10 @@ export interface components {
};
AgentSession: {
id: string;
/** @description Empty for a lead session. */
task_id: string;
/** @description The login whose lead agent this is; empty for a task's session. */
owner: string;
/** @enum {string} */
vendor: "claude";
/** @description Requested model; empty means the vendor's default. */
@@ -826,7 +880,7 @@ export interface components {
*/
state: "idle" | "queued" | "running" | "completed";
/** @enum {string} */
role: "implementer";
role: "implementer" | "lead";
created_by: string;
/** Format: date-time */
created_at: string;
@@ -927,11 +981,22 @@ export interface components {
project_id?: string;
task_id?: string;
agent_session_id?: string;
/** @description Set on a lead agent's events and a user's focus.updated: the login they belong to. Only that user is sent them. */
owner?: string;
actor: string;
payload: Record<string, never>;
/** Format: date-time */
created_at: string;
};
Focus: {
/** @description The task the right pane shows; absent for none. */
task_id?: string;
/** @description The tabs the lead put first, in order. The other task tabs follow them. */
tabs: components["schemas"]["TaskTab"][];
tab: components["schemas"]["TaskTab"];
};
/** @enum {string} */
TaskTab: "transcript" | "changes" | "files" | "inbox" | "todo";
Status: {
/** @description Agent processes currently running. */
live_agents: number;
@@ -1151,6 +1216,97 @@ export interface operations {
};
};
};
listLeadSessions: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The sessions. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AgentSession"][];
};
};
};
};
messageLead: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["MessageCreate"];
};
};
responses: {
/** @description The lead session the message went to or is held for. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AgentSession"];
};
};
400: components["responses"]["Error"];
};
};
getFocus: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description What the calling user's right pane shows. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Focus"];
};
};
};
};
setFocus: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["Focus"];
};
};
responses: {
/** @description The stored focus. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Focus"];
};
};
400: components["responses"]["Error"];
404: components["responses"]["Error"];
};
};
getPushKey: {
parameters: {
query?: never;
@@ -1971,6 +2127,7 @@ export interface operations {
query?: {
after?: number;
task_id?: string;
lead?: boolean;
};
header?: {
"Last-Event-ID"?: number;
+8 -3
View File
@@ -18,6 +18,8 @@ export type Zombie = Schemas["Zombie"];
export type ProtocolErrors = Schemas["ProtocolErrors"];
export type Snapshot = Schemas["Snapshot"];
export type Participant = Schemas["Participant"];
export type Focus = Schemas["Focus"];
export type TaskTab = Schemas["TaskTab"];
type Method = "get" | "post" | "put" | "delete";
@@ -107,7 +109,7 @@ const retryAfterMs = 2_000;
export function subscribe(
after: number,
taskId: string | undefined,
filter: { task_id?: string; lead?: boolean },
onEvent: (ev: ByreEvent) => void,
onState: (connected: boolean) => void,
): () => void {
@@ -121,8 +123,11 @@ export function subscribe(
source?.close();
heard = Date.now();
const query = new URLSearchParams({ after: String(last) });
if (taskId) {
query.set("task_id", taskId);
if (filter.task_id) {
query.set("task_id", filter.task_id);
}
if (filter.lead) {
query.set("lead", "true");
}
const s = new EventSource(`/api/events?${query}`);
source = s;
+17 -10
View File
@@ -1,8 +1,8 @@
import { get, put, subscribe } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { mountNavbar } from "./navbar.js";
import { Pane } from "./panes.js";
import { handleEvent, loadInbox, loadMe, loadProjects, notify, on, selectTask, state } from "./state.js";
import { leadPane, taskPane } from "./panes.js";
import { handleEvent, loadFocus, loadInbox, loadLeadSessions, loadMe, loadProjects, notify, on, selectTask, setFocus, startLeadStream, state } from "./state.js";
function identityBanner(root: HTMLElement): void {
const render = () => {
@@ -34,6 +34,9 @@ function identityBanner(root: HTMLElement): void {
function route(): void {
const match = /^#\/task\/(\w+)$/.exec(location.hash);
const taskId = match?.[1];
if (taskId !== state.focus.task_id) {
setFocus({ task_id: taskId ?? null }).catch((err: unknown) => console.error("set focus", err));
}
if (taskId !== state.selectedTaskId) {
selectTask(taskId);
}
@@ -42,11 +45,18 @@ function route(): void {
async function main(): Promise<void> {
const navbar = h("header", { class: "navbar" });
const banner = h("div", { class: "banner", hidden: true });
const left = new Pane("left", "transcript");
const right = new Pane("right", "inbox");
replace(document.body, navbar, banner, h("div", { class: "panes" }, left.el, right.el));
mountNavbar(navbar, (view) => right.show(view));
const status = await get("/api/admin/status");
await Promise.all([loadMe(), loadProjects(), loadInbox(), loadLeadSessions()]);
const linked = location.hash;
await loadFocus();
if (/^#\/task\/\w+$/.test(linked)) {
location.hash = linked;
}
startLeadStream();
replace(document.body, navbar, banner, h("div", { class: "panes" }, leadPane(), taskPane()));
mountNavbar(navbar);
identityBanner(banner);
addEventListener(
@@ -61,10 +71,7 @@ async function main(): Promise<void> {
{ capture: true },
);
const status = await get("/api/admin/status");
await Promise.all([loadMe(), loadProjects(), loadInbox()]);
subscribe(status.last_event_seq, undefined, handleEvent, (connected) => {
subscribe(status.last_event_seq, {}, handleEvent, (connected) => {
state.connected = connected;
notify("connection");
});
+22
View File
@@ -71,6 +71,28 @@ function modal(text: string, buttons: { label: string; value: string; class?: st
});
}
export function openPanel(title: string, mount: (root: HTMLElement) => () => void): void {
if (document.querySelector("dialog.panel[open]")) {
return;
}
const close = h("button", { type: "button", class: "panel-close", title: "Close (Esc)" }, "×");
const body = h("div", { class: "panel-body" });
const dialog = h("dialog", { class: "modal panel" }, h("header", { class: "panel-head" }, h("h2", {}, title), close), body);
close.onclick = () => dialog.close();
dialog.onclick = (e) => {
if (e.target === dialog || (e.target as Element).closest("a[href^='#']")) {
dialog.close();
}
};
document.body.append(dialog);
const dispose = mount(body);
dialog.onclose = () => {
dispose();
dialog.remove();
};
dialog.showModal();
}
export async function showAlert(text: string): Promise<void> {
await modal(text, [{ label: "OK", value: "ok", class: "primary" }]);
}
+11 -83
View File
@@ -3,51 +3,20 @@ import { mountChanges } from "./changes.js";
import { ago, errorText, h, replace, showAlert } from "./dom.js";
import { image, inlineMarkdown, markdown } from "./markdown.js";
import { diagram } from "./mermaid.js";
import { findTask, onEach, state } from "./state.js";
type Scope = "all" | "project" | "task";
import { onEach, state } from "./state.js";
const checked = new Map<string, string[]>();
const texts = new Map<string, string>();
let scope: Scope = "all";
export function mountInbox(root: HTMLElement): () => void {
const tabs = h("nav", { class: "scope-tabs" });
const list = h("div", { class: "inbox-list" });
const cards = new Map<string, { sig: string; el: HTMLElement }>();
const groups = new Map<string, { head: string; h3: HTMLElement; el: HTMLElement }>();
const empty = h("div", { class: "empty" }, "Nothing waiting on you.");
const visible = (): InboxItem[] => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
if (scope === "task" && task) {
return state.inbox.filter((i) => i.task_id === task.id);
}
if (scope === "project" && task) {
return state.inbox.filter((i) => i.project_id === task.project_id);
}
return state.inbox;
};
const renderTabs = () => {
const hasTask = state.selectedTaskId !== undefined;
const tab = (s: Scope, label: string) => {
const b = h("button", { class: s === scope ? "active" : "", disabled: s !== "all" && !hasTask }, label);
b.onclick = () => {
scope = s;
render();
};
return b;
};
replace(tabs, tab("all", "All"), tab("project", "This project"), tab("task", "This task"));
};
const nothing = h("div", { class: "empty" }, "Nothing waiting on you in this task.");
const noTask = h("div", { class: "empty" }, "No task selected.");
const render = () => {
if (scope !== "all" && !state.selectedTaskId) {
scope = "all";
}
renderTabs();
const items = visible();
const taskId = state.selectedTaskId;
const items = state.inbox.filter((i) => i.task_id === taskId).sort((a, b) => a.created_at.localeCompare(b.created_at));
const keep = new Set(items.map((i) => i.id));
for (const id of cards.keys()) {
if (!keep.has(id)) {
@@ -73,35 +42,16 @@ export function mountInbox(root: HTMLElement): () => void {
return el;
};
const shown = groupByTask(items);
const keepGroups = new Set(shown.map((g) => g.taskId));
for (const id of groups.keys()) {
if (!keepGroups.has(id)) {
groups.delete(id);
}
if (!taskId) {
reconcile(list, [noTask]);
return;
}
const els = shown.map((g) => {
const projectName = state.projects.find((p) => p.id === g.projectId)?.name ?? g.projectId;
const title = findTask(g.taskId)?.title ?? g.taskId;
const head = JSON.stringify([projectName, title]);
let group = groups.get(g.taskId);
if (!group || group.head !== head) {
const h3 = h("h3", {}, h("span", { class: "project-name" }, projectName, " › "), h("a", { href: `#/task/${g.taskId}` }, title));
group = { head, h3, el: group?.el ?? h("section", {}) };
groups.set(g.taskId, group);
}
group.el.className = `task-group ${g.blocked ? "blocked" : ""}`;
reconcile(group.el, [group.h3, ...g.items.map(cardFor)]);
return group.el;
});
reconcile(list, els.length > 0 ? els : [empty]);
reconcile(list, items.length > 0 ? items.map(cardFor) : [nothing]);
};
const off = onEach(["inbox", "selection", "tasks"], render);
const off = onEach(["inbox", "selection"], render);
const changes = h("section", { class: "inbox-changes" });
replace(root, h("div", { class: "inbox-view" }, changes, tabs, list));
replace(root, h("div", { class: "inbox-view" }, changes, list));
const offChanges = mountChanges(changes, true);
render();
return () => {
@@ -122,28 +72,6 @@ function reconcile(parent: Element, nodes: Node[]): void {
}
}
type Group = { taskId: string; projectId: string; blocked: boolean; items: InboxItem[] };
function groupByTask(items: InboxItem[]): Group[] {
const groups = new Map<string, Group>();
for (const item of [...items].sort((a, b) => a.created_at.localeCompare(b.created_at))) {
let g = groups.get(item.task_id);
if (!g) {
g = { taskId: item.task_id, projectId: item.project_id, blocked: false, items: [] };
groups.set(item.task_id, g);
}
g.items.push(item);
g.blocked ||= item.priority === "blocked";
}
const projectName = (id: string) => state.projects.find((p) => p.id === id)?.name ?? id;
return [...groups.values()].sort(
(a, b) =>
Number(b.blocked) - Number(a.blocked) ||
projectName(a.projectId).localeCompare(projectName(b.projectId)) ||
a.items[0]!.created_at.localeCompare(b.items[0]!.created_at),
);
}
function clickable(el: HTMLElement, go: () => void): HTMLElement {
el.onclick = (e) => {
if (!(e.target as HTMLElement).closest("a")) {
+36
View File
@@ -0,0 +1,36 @@
import { post } from "./api.js";
import { h } from "./dom.js";
import { currentLead, state } from "./state.js";
import { compactButton, mountChat, type ChatSource, type Head } from "./task.js";
const leadSource: ChatSource = {
key: () => "lead",
events: () => state.leadTranscript,
session: currentLead,
root: () => "",
taskId: () => undefined,
resetTopics: [],
refreshTopics: ["lead.sessions"],
transcriptTopic: "lead.transcript",
streamTopic: "lead.stream",
empty: "",
canSend: () => true,
placeholder: (s) => (s === "running" ? "Message your lead; it gets this when its turn ends" : "Message your lead. Paste or drop images to attach them."),
send: async ({ text, images, model, firstModel, sessionId }) => {
await post("/api/me/lead/messages", { body: { text, images, model: sessionId ? model : firstModel } });
},
};
function leadHead(): Head {
return {
render: (usage) => {
const sess = currentLead();
const live = sess && sess.state !== "completed" ? sess : undefined;
return [h("div", { class: "head-actions session-actions" }, compactButton(live?.id, live?.state, live && usage(live.id)))];
},
};
}
export function mountLead(root: HTMLElement): () => void {
return mountChat(root, leadSource, leadHead);
}
+11 -15
View File
@@ -1,17 +1,18 @@
import { post } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { openTranscript, type View } from "./panes.js";
import { activeTasks, currentSession, findTask, lastVisits, onEach, state } from "./state.js";
import { openSettings } from "./settings.js";
import { activeTasks, currentSession, findTask, focusTask, lastVisits, onEach, state } from "./state.js";
import { openStatus } from "./status.js";
export function mountNavbar(root: HTMLElement, show: (view: View) => void): void {
export function mountNavbar(root: HTMLElement): void {
const crumb = h("button", { class: "crumb", title: "Switch task (⌘K)" });
crumb.onclick = openSwitcher;
const inbox = h("button", { class: "inbox-count" });
inbox.onclick = () => show("inbox");
const status = h("button", { class: "nav-link" }, "Status");
status.onclick = openStatus;
const admin = h("button", { class: "nav-link" }, "Admin");
admin.onclick = () => show("admin");
const settings = h("button", { class: "nav-link" }, "Settings");
settings.onclick = openSettings;
const conn = h("span", { class: "connection down", title: "connecting" });
const connLabel = h("span", { class: "connection-label" }, "reconnecting…");
@@ -22,18 +23,14 @@ export function mountNavbar(root: HTMLElement, show: (view: View) => void): void
const project = task ? state.projects.find((p) => p.id === task.project_id) : undefined;
replace(crumb, task ? `${project?.name ?? ""} ▸ ${task.title}` : "No task", " ▾");
const blocked = state.inbox.filter((i) => i.priority === "blocked").length;
inbox.className = blocked > 0 ? "inbox-count urgent" : "inbox-count";
replace(inbox, `Inbox ${state.inbox.length}`, blocked > 0 && h("span", { class: "blocked-count" }, ` · ${blocked} blocked`));
conn.className = state.connected ? "connection up" : "connection down";
conn.title = state.connected ? "live" : "reconnecting";
connLabel.hidden = state.connected;
replace(me, state.me?.display_name ?? "");
};
onEach(["me", "projects", "tasks", "inbox", "selection", "connection"], render);
replace(root, h("a", { href: "#/", class: "brand" }, "byre"), crumb, h("span", { class: "spacer" }), inbox, admin, connLabel, conn, me);
onEach(["me", "projects", "tasks", "selection", "connection"], render);
replace(root, h("a", { href: "#/", class: "brand" }, "byre"), crumb, h("span", { class: "spacer" }), status, settings, connLabel, conn, me);
render();
addEventListener("keydown", (e) => {
@@ -75,8 +72,7 @@ function openSwitcher(): void {
const go = (taskId: string) => {
close();
location.hash = `#/task/${taskId}`;
openTranscript();
focusTask(taskId);
};
const create = async (projectId: string, title: string) => {
+34 -64
View File
@@ -1,86 +1,56 @@
import { mountAdmin } from "./admin.js";
import type { TaskTab } from "./api.js";
import { mountChanges } from "./changes.js";
import { h, replace } from "./dom.js";
import { mountFiles } from "./files.js";
import { mountInbox } from "./inbox.js";
import { mountSettings } from "./settings.js";
import { mountLead } from "./lead.js";
import { focusTab, on, state, tabs } from "./state.js";
import { mountTranscript } from "./task.js";
import { mountTasks } from "./tasks.js";
import { mountTodo } from "./todo.js";
export type View = "transcript" | "changes" | "files" | "tasks" | "todo" | "inbox" | "admin" | "settings";
const views: Record<View, { label: string; mount: (root: HTMLElement) => () => void }> = {
const views: Record<TaskTab, { label: string; mount: (root: HTMLElement) => () => void }> = {
transcript: { label: "Transcript", mount: mountTranscript },
changes: { label: "Changes", mount: (root) => mountChanges(root) },
files: { label: "Files", mount: mountFiles },
tasks: { label: "Tasks", mount: mountTasks },
todo: { label: "Todo", mount: mountTodo },
inbox: { label: "Inbox", mount: mountInbox },
admin: { label: "Admin", mount: mountAdmin },
settings: { label: "Settings", mount: mountSettings },
todo: { label: "Todo", mount: mountTodo },
};
const order: View[] = ["transcript", "changes", "files", "tasks", "todo", "inbox", "admin", "settings"];
const panes: Pane[] = [];
export function openTranscript(): void {
if (panes.some((p) => p.current() === "transcript")) {
return;
}
(panes.find((p) => p.current() !== "tasks") ?? panes[0])?.show("transcript");
export function leadPane(): HTMLElement {
const root = h("div", { class: "view view-lead" });
mountLead(root);
return h("section", { class: "pane" }, h("nav", { class: "pane-tabs" }, h("button", { class: "active" }, "Lead")), h("div", { class: "pane-body" }, root));
}
export function openChanges(from: Element): void {
if (panes.some((p) => p.current() === "changes")) {
return;
}
(panes.find((p) => !p.el.contains(from)) ?? panes[0])?.show("changes");
}
export class Pane {
readonly el: HTMLElement;
private readonly tabs: HTMLElement;
private readonly body: HTMLElement;
private view: View;
private dispose: () => void = () => {};
constructor(private readonly key: string, fallback: View) {
this.tabs = h("nav", { class: "pane-tabs" });
this.body = h("div", { class: "pane-body" });
this.el = h("section", { class: "pane" }, this.tabs, this.body);
const saved = localStorage.getItem(this.storageKey()) as View | null;
this.view = saved && saved in views ? saved : fallback;
this.show(this.view);
panes.push(this);
}
current(): View {
return this.view;
}
show(view: View): void {
this.dispose();
this.view = view;
localStorage.setItem(this.storageKey(), view);
export function taskPane(): HTMLElement {
const strip = h("nav", { class: "pane-tabs" });
const body = h("div", { class: "pane-body" });
let shown: TaskTab | undefined;
let dispose: () => void = () => {};
const render = () => {
const first = state.focus.tabs.filter((t, i, all) => tabs.includes(t) && all.indexOf(t) === i);
const order = [...first, ...tabs.filter((t) => !first.includes(t))];
const tab = state.focus.tab;
replace(
this.tabs,
...order.map((v) => {
const b = h("button", { class: v === view ? "active" : "" }, views[v].label);
b.onclick = () => this.show(v);
strip,
...order.map((t) => {
const b = h("button", { class: t === tab ? "active" : "" }, views[t].label);
b.onclick = () => focusTab(t);
return b;
}),
);
if (tab === shown) {
return;
}
dispose();
shown = tab;
const root = h("div", { class: `view view-${tab}` });
replace(body, root);
dispose = views[tab].mount(root);
};
const root = h("div", { class: `view view-${view}` });
replace(this.body, root);
this.dispose = views[view].mount(root);
}
private storageKey(): string {
return `byre.pane.${this.key}`;
}
on("focus", render);
render();
return h("section", { class: "pane" }, strip, body);
}
+6 -2
View File
@@ -1,5 +1,5 @@
import { del, get, post, put, type NotificationPrefs } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { errorText, h, openPanel, replace, showAlert } from "./dom.js";
import { on, state } from "./state.js";
const kinds: { key: keyof NotificationPrefs; label: string }[] = [
@@ -10,7 +10,11 @@ const kinds: { key: keyof NotificationPrefs; label: string }[] = [
{ key: "rate_limits", label: "Rate limits" },
];
export function mountSettings(root: HTMLElement): () => void {
export function openSettings(): void {
openPanel("Settings", mountSettings);
}
function mountSettings(root: HTMLElement): () => void {
const device = h("div", { class: "push-device" });
const prefs = h("fieldset", { class: "push-prefs" });
+138 -44
View File
@@ -1,6 +1,23 @@
import { get, subscribe, type AgentSession, type ByreEvent, type InboxItem, type Project, type Snapshot, type Task, type User } from "./api.js";
import { get, put, subscribe, type AgentSession, type ByreEvent, type Focus, type InboxItem, type Project, type Snapshot, type Task, type TaskTab, type User } from "./api.js";
export type Topic = "me" | "projects" | "tasks" | "sessions" | "inbox" | "selection" | "transcript" | "stream" | "connection" | "event" | "pin";
export type Topic =
| "me"
| "projects"
| "tasks"
| "sessions"
| "inbox"
| "selection"
| "transcript"
| "stream"
| "connection"
| "event"
| "pin"
| "focus"
| "lead.sessions"
| "lead.transcript"
| "lead.stream";
export const tabs: TaskTab[] = ["transcript", "changes", "files", "inbox", "todo"];
export const state = {
me: undefined as User | undefined,
@@ -10,9 +27,11 @@ export const state = {
inbox: [] as InboxItem[],
selectedTaskId: undefined as string | undefined,
transcript: [] as ByreEvent[],
lastTranscriptSeq: 0,
pinned: undefined as number | undefined,
connected: false,
focus: { tabs: ["transcript"], tab: "transcript" } as Focus,
leadSessions: [] as AgentSession[],
leadTranscript: [] as ByreEvent[],
};
const listeners = new Map<Topic, Set<(detail?: unknown) => void>>();
@@ -60,6 +79,10 @@ export function currentSession(taskId: string): AgentSession | undefined {
return state.sessions.get(taskId)?.findLast((s) => s.state !== "completed") ?? state.sessions.get(taskId)?.at(-1);
}
export function currentLead(): AgentSession | undefined {
return state.leadSessions.findLast((s) => s.state !== "completed") ?? state.leadSessions.at(-1);
}
export function snapshots(): Snapshot[] {
return state.transcript.filter((e) => e.kind === "snapshot.created").map((e) => e.payload as unknown as Snapshot);
}
@@ -97,9 +120,106 @@ export async function loadInbox(): Promise<void> {
notify("inbox");
}
export async function loadLeadSessions(): Promise<void> {
state.leadSessions = await get("/api/me/lead/sessions");
notify("lead.sessions");
}
export async function loadFocus(): Promise<void> {
applyFocus(await get("/api/me/focus"));
}
export function applyFocus(f: Focus): void {
state.focus = f;
const hash = f.task_id ? `#/task/${f.task_id}` : "#/";
if (location.hash !== hash && !(hash === "#/" && location.hash === "")) {
location.hash = hash;
}
notify("focus");
}
export async function setFocus(change: { task_id?: string | null; tab?: TaskTab }): Promise<void> {
const taskId = change.task_id === undefined ? state.focus.task_id : (change.task_id ?? undefined);
const next: Focus = { tabs: state.focus.tabs, tab: change.tab ?? state.focus.tab };
if (taskId) {
next.task_id = taskId;
}
applyFocus(next);
applyFocus(await put("/api/me/focus", { body: next }));
}
export function focusTask(taskId: string, tab: TaskTab = "transcript"): void {
setFocus({ task_id: taskId, tab }).catch((err: unknown) => console.error("set focus", err));
}
export function focusTab(tab: TaskTab): void {
setFocus({ tab }).catch((err: unknown) => console.error("set focus", err));
}
class Feed {
events: ByreEvent[] = [];
private last = 0;
private buffer: ByreEvent[] = [];
private timer = 0;
constructor(
private readonly transcriptTopic: Topic,
private readonly streamTopic: Topic,
private readonly store: (events: ByreEvent[]) => void,
) {}
reset(): void {
clearTimeout(this.timer);
this.timer = 0;
this.buffer = [];
this.events = [];
this.last = 0;
this.store(this.events);
}
readonly push = (ev: ByreEvent): void => {
this.buffer.push(ev);
if (!this.timer) {
this.timer = setTimeout(() => this.flush(), 0);
}
};
private flush(): void {
const events = this.buffer;
this.buffer = [];
this.timer = 0;
let stored: ByreEvent[] = [];
for (const ev of events) {
if (ev.seq !== 0) {
stored.push(ev);
continue;
}
this.append(stored);
stored = [];
notify(this.streamTopic, ev);
}
this.append(stored);
}
private append(events: ByreEvent[]): void {
const fresh = events.filter((ev) => ev.seq > this.last);
if (fresh.length === 0) {
return;
}
this.events.push(...fresh);
this.last = fresh.at(-1)!.seq;
notify(this.transcriptTopic, fresh);
}
}
const taskFeed = new Feed("transcript", "stream", (events) => {
state.transcript = events;
});
const leadFeed = new Feed("lead.transcript", "lead.stream", (events) => {
state.leadTranscript = events;
});
let closeTaskStream: (() => void) | undefined;
let taskBuffer: ByreEvent[] = [];
let taskFlush = 0;
const visitsKey = "byre.visits";
@@ -119,12 +239,8 @@ export function selectTask(taskId: string | undefined): void {
markVisited(taskId);
closeTaskStream?.();
closeTaskStream = undefined;
clearTimeout(taskFlush);
taskFlush = 0;
taskBuffer = [];
taskFeed.reset();
state.selectedTaskId = taskId;
state.transcript = [];
state.lastTranscriptSeq = 0;
state.pinned = undefined;
notify("selection");
@@ -132,41 +248,12 @@ export function selectTask(taskId: string | undefined): void {
return;
}
closeTaskStream = subscribe(0, taskId, queueTaskEvent, () => undefined);
closeTaskStream = subscribe(0, { task_id: taskId }, taskFeed.push, () => undefined);
}
function queueTaskEvent(ev: ByreEvent): void {
taskBuffer.push(ev);
if (!taskFlush) {
taskFlush = setTimeout(flushTaskEvents, 0);
}
}
function flushTaskEvents(): void {
const events = taskBuffer;
taskBuffer = [];
taskFlush = 0;
let stored: ByreEvent[] = [];
for (const ev of events) {
if (ev.seq !== 0) {
stored.push(ev);
continue;
}
appendTranscript(stored);
stored = [];
notify("stream", ev);
}
appendTranscript(stored);
}
function appendTranscript(events: ByreEvent[]): void {
const fresh = events.filter((ev) => ev.seq > state.lastTranscriptSeq);
if (fresh.length === 0) {
return;
}
state.transcript.push(...fresh);
state.lastTranscriptSeq = fresh.at(-1)!.seq;
notify("transcript", fresh);
export function startLeadStream(): void {
leadFeed.reset();
subscribe(0, { lead: true }, leadFeed.push, () => undefined);
}
export function handleEvent(ev: ByreEvent): void {
@@ -195,7 +282,9 @@ export function handleEvent(ev: ByreEvent): void {
case "session.created":
case "session.state":
case "session.model":
if (ev.task_id) {
if (ev.owner) {
void loadLeadSessions();
} else if (ev.task_id) {
void loadSessions(ev.task_id);
}
break;
@@ -203,6 +292,11 @@ export function handleEvent(ev: ByreEvent): void {
case "inbox.updated":
void loadInbox();
break;
case "focus.updated":
if (ev.actor !== state.me?.login) {
applyFocus(ev.payload as unknown as Focus);
}
break;
}
notify("event", ev);
+14 -10
View File
@@ -1,10 +1,14 @@
import { get, type AgentProcess, type ByreEvent, type Status, type TurnRef, type Zombie } from "./api.js";
import { ago, errorText, h, replace } from "./dom.js";
import { ago, errorText, h, openPanel, replace } from "./dom.js";
import { findTask, on, onEach, state } from "./state.js";
const refreshKinds = new Set(["session.created", "session.state", "task.created", "task.merged", "task.discarded", "project.created", "admin.health"]);
export function mountAdmin(root: HTMLElement): () => void {
export function openStatus(): void {
openPanel("Status", mountStatus);
}
function mountStatus(root: HTMLElement): () => void {
let status: Status | undefined;
let error = "";
let inFlight = false;
@@ -16,13 +20,13 @@ export function mountAdmin(root: HTMLElement): () => void {
replace(root, h("div", { class: error ? "empty error-text" : "empty" }, error || "Loading…"));
return;
}
const scrollTop = root.querySelector(".admin-view")?.scrollTop ?? 0;
const scrollTop = root.querySelector(".status-view")?.scrollTop ?? 0;
const open = new Set([...root.querySelectorAll<HTMLDetailsElement>(".problems details[open]")].map((d) => d.dataset["vendor"] ?? ""));
replace(
root,
h(
"div",
{ class: "admin-view" },
{ class: "status-view" },
error && h("p", { class: "error-text" }, error),
problemsSection(status, open),
turnsSection(status),
@@ -30,7 +34,7 @@ export function mountAdmin(root: HTMLElement): () => void {
serverSection(status),
),
);
root.querySelector(".admin-view")!.scrollTop = scrollTop;
root.querySelector(".status-view")!.scrollTop = scrollTop;
};
const refresh = async () => {
@@ -108,7 +112,7 @@ function turnsSection(s: Status): HTMLElement {
return h(
"section",
{ class: "admin-section" },
{ class: "status-section" },
h("h2", {}, "Turns"),
h(
"div",
@@ -160,7 +164,7 @@ function turnList(turns: TurnRef[], kind: "running" | "queued", empty: string, p
function taskLink(taskId: string | undefined): HTMLElement | string {
if (!taskId) {
return "—";
return "lead";
}
const task = findTask(taskId);
const project = task ? state.projects.find((p) => p.id === task.project_id) : undefined;
@@ -201,7 +205,7 @@ function problemsSection(s: Status, openVendors: Set<string>): HTMLElement | fal
}
return h(
"section",
{ class: "admin-section" },
{ class: "status-section" },
h("h2", {}, "Problems"),
h(
"ul",
@@ -231,7 +235,7 @@ function rateLimitsSection(limits: Status["rate_limits"]): HTMLElement {
const vendors = Object.keys(limits).sort();
return h(
"section",
{ class: "admin-section" },
{ class: "status-section" },
h("h2", {}, "Rate limits"),
...vendors.map((v) => rateLimitCard(v, limits[v] ?? {})),
vendors.length === 0 && h("p", { class: "hint" }, "No vendor has reported a rate-limit status since byre started."),
@@ -313,7 +317,7 @@ function serverSection(s: Status): HTMLElement {
["Database", bytes(s.db_bytes)],
["Last event", `#${s.last_event_seq}`],
];
return h("section", { class: "admin-section" }, h("h2", {}, "Server"), h("dl", { class: "kv" }, ...rows.flatMap(([k, v]) => [h("dt", {}, k), h("dd", {}, v)])));
return h("section", { class: "status-section" }, h("h2", {}, "Server"), h("dl", { class: "kv" }, ...rows.flatMap(([k, v]) => [h("dt", {}, k), h("dd", {}, v)])));
}
function bytes(n: number): string {
+189 -137
View File
@@ -1,8 +1,32 @@
import { get, post, type ByreEvent, type Participant, type Snapshot } from "./api.js";
import { get, post, type AgentSession, type ByreEvent, type Participant, type Snapshot } 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, type Topic } from "./state.js";
export type Usage = { used: number; window: number; model: string };
export type ChatSource = {
key: () => string | undefined;
events: () => ByreEvent[];
session: () => AgentSession | undefined;
root: () => string;
taskId: () => string | undefined;
resetTopics: Topic[];
refreshTopics: Topic[];
transcriptTopic: Topic;
streamTopic: Topic;
empty: string;
canSend: (sessionState: string) => boolean;
placeholder: (sessionState: string) => string;
send: (m: { key: string; sessionId: string | undefined; text: string; images: string[] | undefined; model: string | undefined; firstModel: string }) => Promise<void>;
};
export type Head = {
render: (usage: (sessionId: string) => Usage | undefined) => (Node | false | null)[];
onEvents?: (events: ByreEvent[]) => void;
reset?: () => void;
dispose?: () => void;
};
export const models = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"];
@@ -30,12 +54,127 @@ type Ctx = {
anchors: Anchor[];
unplaced: Snapshot[];
root: string;
taskId: string | undefined;
lastMessage: HTMLElement | undefined;
turnStart: HTMLElement | undefined;
watchReport: (el: HTMLElement, itemId: string) => void;
};
const taskSource: ChatSource = {
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: HTMLElement): () => void {
return mountChat(root, taskSource, taskHead);
}
function taskHead(rerender: () => void): Head {
let unmerged: { taskId: string; has: boolean } | undefined;
let timer: number | undefined;
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: HTMLElement, src: ChatSource, makeHead: (rerender: () => void) => Head): () => void {
const head = h("header", { class: "task-header" });
const transcript = h("div", { class: "transcript" });
const composer = h("div", { class: "composer" });
@@ -78,46 +217,23 @@ export function mountTranscript(root: HTMLElement): () => void {
{ root: transcript, threshold: 0.5 },
);
const newCtx = (): Ctx => {
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 = (): Ctx => ({
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<string, { used: number; window: number; model: string }>();
let unmerged: { taskId: string; has: boolean } | undefined;
let unmergedTimer: number | undefined;
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);
}
};
let context = new Map<string, Usage>();
const headImpl = makeHead(() => renderHead());
const spinner = h("div", { class: "spinner", hidden: true });
const setRunning = (running: boolean) => {
@@ -143,71 +259,26 @@ export function mountTranscript(root: HTMLElement): () => void {
const put = (el: HTMLElement) => 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);
@@ -343,7 +414,7 @@ export function mountTranscript(root: HTMLElement): () => void {
}
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;
}
@@ -367,12 +438,9 @@ export function mountTranscript(root: HTMLElement): () => void {
const appendEvents = (events: ByreEvent[]) => {
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
let contextChanged = false;
headImpl.onEvents?.(events);
for (const ev of events) {
const p = ev.payload as Record<string, unknown>;
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();
}
@@ -420,7 +488,7 @@ export function mountTranscript(root: HTMLElement): () => void {
}
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();
}
@@ -438,33 +506,26 @@ export function mountTranscript(root: HTMLElement): () => void {
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 as ByreEvent[])),
onEach(["stream"], (ev) => appendDelta(ev as ByreEvent)),
onEach([src.transcriptTopic], (fresh) => appendEvents(fresh as ByreEvent[])),
onEach([src.streamTopic], (ev) => appendDelta(ev as ByreEvent)),
onEach(["inbox"], ackVisible),
];
document.addEventListener("visibilitychange", ackVisible);
@@ -476,7 +537,7 @@ export function mountTranscript(root: HTMLElement): () => void {
off();
}
document.removeEventListener("visibilitychange", ackVisible);
clearTimeout(unmergedTimer);
headImpl.dispose?.();
observer.disconnect();
dropStream();
};
@@ -623,18 +684,16 @@ function modelSelect(key: string, current: string): HTMLSelectElement {
}
function composeForm(
taskId: string,
src: ChatSource,
key: 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,
});
const clearDraft = keepDraft(text, `message:${taskId}`);
const send = h("button", { type: "submit", disabled: sessionState !== "idle" }, "Send");
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" },
@@ -651,15 +710,15 @@ function composeForm(
}
};
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();
@@ -668,18 +727,11 @@ function composeForm(
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));
@@ -698,7 +750,7 @@ function submitOnEnter(input: HTMLTextAreaElement, form: HTMLFormElement): void
};
}
function compactButton(sessionId: string | undefined, sessionState: string | undefined, c: { used: number; window: number; model: string } | undefined): HTMLElement {
export function compactButton(sessionId: string | undefined, sessionState: string | undefined, c: { used: number; window: number; model: string } | undefined): HTMLElement {
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})` : ""}` : "";
@@ -751,7 +803,7 @@ function snapMark(ns: number[]): HTMLElement | null {
e.preventDefault();
e.stopPropagation();
pinSnapshot(hi);
openChanges(b);
focusTab("changes");
};
return b;
}
-74
View File
@@ -1,74 +0,0 @@
import { post, type Project } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { openTranscript } from "./panes.js";
import { currentSession, onEach, state } from "./state.js";
export function mountTasks(root: HTMLElement): () => void {
const render = () => replace(root, h("div", { class: "tasks-view" }, ...state.projects.map(projectBlock), newProjectForm()));
const off = onEach(["projects", "tasks", "sessions", "inbox", "selection"], render);
render();
return off;
}
function projectBlock(project: Project): HTMLElement {
const tasks = (state.tasks.get(project.id) ?? []).filter((t) => t.status === "active");
return h(
"section",
{ class: "project" },
h("h2", {}, project.name, " ", h("span", { class: "repo" }, project.repo_path)),
h(
"ul",
{ class: "task-list" },
...tasks.map((task) => {
const sess = currentSession(task.id);
const count = state.inbox.filter((item) => item.task_id === task.id).length;
return h(
"li",
{ class: task.id === state.selectedTaskId ? "selected" : "" },
h(
"a",
{ href: `#/task/${task.id}`, onclick: openTranscript },
h("span", { class: `dot ${sess?.state ?? "none"}`, title: sess?.state ?? "no session" }),
h("span", { class: "task-title" }, task.title),
h("span", { class: "task-state" }, sess?.state ?? "no agent"),
count > 0 && h("span", { class: "badge" }, count),
),
);
}),
tasks.length === 0 && h("li", { class: "empty-row" }, "No active tasks."),
),
newTaskForm(project.id),
);
}
function newTaskForm(projectId: string): HTMLElement {
const title = h("input", { placeholder: "New task title", required: true });
const form = h("form", { class: "inline-form" }, title, h("button", { type: "submit" }, "Create"));
form.onsubmit = async (e) => {
e.preventDefault();
try {
const task = await post("/api/projects/{projectId}/tasks", { path: { projectId }, body: { title: title.value } });
location.hash = `#/task/${task.id}`;
} catch (err) {
void showAlert(errorText(err));
}
};
return form;
}
function newProjectForm(): HTMLElement {
const name = h("input", { placeholder: "Name", required: true });
const path = h("input", { placeholder: "Repo path on the server", required: true });
const form = h("form", { class: "stack-form" }, name, path, h("button", { type: "submit" }, "Add project"));
form.onsubmit = async (e) => {
e.preventDefault();
try {
await post("/api/projects", { body: { name: name.value, repo_path: path.value } });
form.reset();
} catch (err) {
void showAlert(errorText(err));
}
};
return h("details", { class: "add-project", open: state.projects.length === 0 }, h("summary", {}, "Add project"), form);
}
+27 -8
View File
@@ -1,9 +1,8 @@
import { get, post, type ByreEvent, type Project, type Schemas } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { markdown } from "./markdown.js";
import { openTranscript } from "./panes.js";
import { models } from "./task.js";
import { currentSession, on, onEach, state } from "./state.js";
import { currentSession, findTask, focusTask, on, onEach, state } from "./state.js";
type Todo = Schemas["Todo"];
type TodoItem = Schemas["TodoItem"];
@@ -15,9 +14,15 @@ export function mountTodo(root: HTMLElement): () => void {
const starting = new Set<string>();
let disposed = false;
const project = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
return state.projects.find((p) => p.id === task?.project_id);
};
const render = () => {
const scrollTop = root.querySelector(".todo-view")?.scrollTop ?? 0;
replace(root, h("div", { class: "todo-view" }, ...state.projects.map((p) => projectBlock(p, todos.get(p.id), starting, start))));
const p = project();
replace(root, h("div", { class: "todo-view" }, p ? projectBlock(p, todos.get(p.id), starting, start) : h("div", { class: "empty" }, "No task selected.")));
root.querySelector(".todo-view")!.scrollTop = scrollTop;
};
@@ -34,7 +39,8 @@ export function mountTodo(root: HTMLElement): () => void {
};
const refresh = () => {
for (const p of state.projects) {
const p = project();
if (p) {
void load(p.id);
}
};
@@ -45,8 +51,7 @@ export function mountTodo(root: HTMLElement): () => void {
render();
try {
const task = await post("/api/projects/{projectId}/todo/tasks", { path: { projectId: project.id }, body: { title: item.title, model: models[0] } });
location.hash = `#/task/${task.id}`;
openTranscript();
focusTask(task.id);
} catch (err) {
void showAlert(errorText(err));
}
@@ -72,7 +77,14 @@ export function mountTodo(root: HTMLElement): () => void {
}
}),
on("projects", refresh),
onEach(["tasks", "sessions", "selection"], render),
on("selection", () => {
render();
const p = project();
if (p && !todos.has(p.id)) {
void load(p.id);
}
}),
onEach(["tasks", "sessions"], render),
];
render();
@@ -122,7 +134,14 @@ function projectBlock(project: Project, todo: Todo | string | undefined, startin
const sess = currentSession(task.id);
action = h(
"a",
{ class: "todo-task", href: `#/task/${task.id}`, onclick: openTranscript },
{
class: "todo-task",
href: `#/task/${task.id}`,
onclick: (e: MouseEvent) => {
e.preventDefault();
focusTask(task.id);
},
},
h("span", { class: `dot ${sess?.state ?? "none"}`, title: sess?.state ?? "no session" }),
sess?.state ?? "no agent",
);
+44 -55
View File
@@ -208,17 +208,6 @@ textarea {
flex: 1;
}
.inbox-count {
background: transparent;
}
.inbox-count.urgent {
background: var(--danger-tint);
border-color: transparent;
color: var(--danger);
font-weight: 700;
}
.connection {
width: 8px;
height: 8px;
@@ -282,7 +271,6 @@ textarea {
}
.pane-tabs button,
.scope-tabs button,
.range-tabs button,
.modes button {
border: none;
@@ -297,14 +285,12 @@ textarea {
}
.pane-tabs button:hover:not(:disabled),
.scope-tabs button:hover:not(:disabled),
.range-tabs button:hover:not(:disabled),
.modes button:hover:not(:disabled) {
color: var(--text);
}
.pane-tabs button.active,
.scope-tabs button.active,
.range-tabs button.active,
.modes button.active {
color: var(--text);
@@ -323,11 +309,6 @@ textarea {
flex-direction: column;
}
.tasks-view {
overflow-y: auto;
padding: 8px 16px 16px;
}
.todo-view {
overflow-y: auto;
padding: 8px 16px 16px;
@@ -1198,17 +1179,6 @@ pre.file-text .line::before {
padding: 6px 16px 16px;
}
.scope-tabs {
display: flex;
gap: 4px;
padding: 4px 12px 0;
}
.scope-tabs button {
padding: 6px 8px 4px;
font-size: 12px;
}
.range-tabs {
display: flex;
gap: 4px;
@@ -1260,28 +1230,6 @@ pre.file-text .line::before {
color: var(--danger);
}
.task-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.task-group + .task-group {
margin-top: 8px;
}
.task-group h3 {
margin: 0;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-group.blocked h3 a {
color: var(--danger);
}
.context {
margin: 10px 0;
}
@@ -1456,6 +1404,45 @@ pre.file-text .line::before {
background: rgb(0 0 0 / 0.35);
}
.modal.panel {
width: min(860px, 92vw);
height: min(80vh, 900px);
padding: 0;
overflow: hidden;
}
.modal.panel[open] {
display: flex;
flex-direction: column;
}
.panel-head {
display: flex;
align-items: center;
padding: 8px 12px 8px 16px;
border-bottom: 1px solid var(--border);
}
.panel-head h2 {
flex: 1;
margin: 0;
font-size: 14px;
}
.panel-close {
border-color: transparent;
background: transparent;
font-size: 16px;
line-height: 1;
}
.panel-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.modal-text {
margin: 0 0 16px;
white-space: pre-wrap;
@@ -1529,6 +1516,8 @@ button.danger-primary {
}
.settings-view {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px 16px 16px;
}
@@ -1575,19 +1564,19 @@ button.danger-primary {
background: transparent;
}
.admin-view {
.status-view {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px 16px 16px;
}
.admin-section h2 {
.status-section h2 {
font-size: 13px;
margin: 16px 0 8px;
}
.admin-section h3 {
.status-section h3 {
font-size: 12px;
color: var(--muted);
margin: 12px 0 4px;