SSE reconnect, task switcher state/inbox/new task, stream throttle

This commit is contained in:
Ian Gulliver
2026-10-14 22:28:25 -07:00
parent 8e4c40eb5f
commit 33e682a9d5
27 changed files with 418 additions and 182 deletions
+61 -23
View File
@@ -1,5 +1,7 @@
import { h, replace } from "./dom.js";
import { activeTasks, findTask, onEach, state } from "./state.js";
import { post } from "./api.js";
import { errorText, h, replace } from "./dom.js";
import { openTranscript } from "./panes.js";
import { activeTasks, currentSession, findTask, onEach, state } from "./state.js";
export function mountNavbar(root, show) {
const crumb = h("button", { class: "crumb", title: "Switch task (⌘K)" });
crumb.onclick = openSwitcher;
@@ -8,6 +10,7 @@ export function mountNavbar(root, show) {
const admin = h("button", { class: "nav-link" }, "Admin");
admin.onclick = () => show("admin");
const conn = h("span", { class: "connection down", title: "connecting" });
const connLabel = h("span", { class: "connection-label" }, "reconnecting…");
const me = h("span", { class: "me" });
const render = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
@@ -18,10 +21,11 @@ export function mountNavbar(root, show) {
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, conn, me);
replace(root, h("a", { href: "#/", class: "brand" }, "byre"), crumb, h("span", { class: "spacer" }), inbox, admin, connLabel, conn, me);
render();
addEventListener("keydown", (e) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
@@ -34,44 +38,80 @@ function openSwitcher() {
if (document.querySelector(".switcher")) {
return;
}
const input = h("input", { placeholder: "Go to task…", autocomplete: "off" });
const input = h("input", { placeholder: "Go to a task, or type a title to create one…", autocomplete: "off" });
const list = h("ul", { class: "switcher-list" });
const backdrop = h("div", { class: "switcher-backdrop" });
const box = h("div", { class: "switcher" }, input, list);
let selected = 0;
let matches = [];
let rows = [];
const offs = onEach(["projects", "tasks", "sessions", "inbox"], () => render());
const onEscape = (e) => {
if (e.key !== "Escape") {
return;
}
e.preventDefault();
e.stopPropagation();
close();
};
addEventListener("keydown", onEscape, { capture: true });
const close = () => {
offs();
removeEventListener("keydown", onEscape, { capture: true });
backdrop.remove();
box.remove();
};
const go = (taskId) => {
close();
location.hash = `#/task/${taskId}`;
openTranscript();
};
const create = async (projectId, title) => {
close();
try {
const task = await post("/api/projects/{projectId}/tasks", { path: { projectId }, body: { title } });
go(task.id);
}
catch (err) {
alert(errorText(err));
}
};
const render = () => {
const q = input.value.trim().toLowerCase();
matches = activeTasks().filter(({ project, task }) => `${project.name} ${task.title}`.toLowerCase().includes(q));
selected = Math.min(selected, Math.max(matches.length - 1, 0));
replace(list, ...matches.map(({ project, task }, i) => {
const li = h("li", { class: i === selected ? "selected" : "" }, h("span", { class: "project-name" }, project.name), " ", task.title);
li.onmousedown = (e) => {
const title = input.value.trim();
const q = title.toLowerCase();
const inbox = (taskId) => state.inbox.filter((item) => item.task_id === taskId).length;
const matches = activeTasks()
.filter(({ project, task }) => `${project.name} ${task.title}`.toLowerCase().includes(q))
.map((m) => ({ ...m, count: inbox(m.task.id) }))
.sort((a, b) => Number(b.count > 0) - Number(a.count > 0));
rows = matches.map(({ project, task, count }) => {
const sess = currentSession(task.id);
const el = h("li", {}, h("span", { class: `dot ${sess?.state ?? "none"}`, title: sess?.state ?? "no session" }), h("span", { class: "task-title" }, h("span", { class: "project-name" }, project.name), " ", task.title), count > 0 && h("span", { class: "badge", title: "inbox items" }, count), h("span", { class: "task-state" }, sess?.state ?? "no agent"));
return { el, pick: () => go(task.id) };
});
const current = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
const project = state.projects.find((p) => p.id === current?.project_id) ?? state.projects[0];
if (title && project) {
const el = h("li", { class: "new-task" }, h("span", { class: "task-title" }, "New task in ", h("span", { class: "project-name" }, project.name), ": ", title));
rows.push({ el, pick: () => void create(project.id, title) });
}
selected = Math.min(selected, Math.max(rows.length - 1, 0));
rows.forEach((row, i) => {
row.el.classList.toggle("selected", i === selected);
row.el.onmousedown = (e) => {
e.preventDefault();
go(task.id);
row.pick();
};
return li;
}), matches.length === 0 && h("li", { class: "empty-row" }, "No matching tasks."));
});
replace(list, ...rows.map((r) => r.el), rows.length === 0 && h("li", { class: "empty-row" }, "No active tasks. Type a title to create one."));
};
input.oninput = () => {
selected = 0;
render();
};
input.onkeydown = (e) => {
if (e.key === "Escape") {
close();
}
else if (e.key === "ArrowDown") {
if (e.key === "ArrowDown") {
e.preventDefault();
selected = Math.min(selected + 1, matches.length - 1);
selected = Math.min(selected + 1, rows.length - 1);
render();
}
else if (e.key === "ArrowUp") {
@@ -80,10 +120,8 @@ function openSwitcher() {
render();
}
else if (e.key === "Enter") {
const match = matches[selected];
if (match) {
go(match.task.id);
}
e.preventDefault();
rows[selected]?.pick();
}
};
backdrop.onclick = close;