SSE reconnect, task switcher state/inbox/new task, stream throttle
This commit is contained in:
+54
-6
@@ -94,10 +94,58 @@ export const post = <P extends PathsWith<"post">>(path: P, opts?: Options<Op<P,
|
||||
export const put = <P extends PathsWith<"put">>(path: P, opts?: Options<Op<P, "put">>) => request("put", path, opts);
|
||||
export const del = <P extends PathsWith<"delete">>(path: P, opts?: Options<Op<P, "delete">>) => request("delete", path, opts);
|
||||
|
||||
export function subscribe(after: number, onEvent: (ev: ByreEvent) => void, onState: (connected: boolean) => void): EventSource {
|
||||
const source = new EventSource(`/api/events?after=${after}`);
|
||||
source.onopen = () => onState(true);
|
||||
source.onerror = () => onState(false);
|
||||
source.onmessage = (msg: MessageEvent<string>) => onEvent(JSON.parse(msg.data) as ByreEvent);
|
||||
return source;
|
||||
const staleAfterMs = 40_000;
|
||||
const retryAfterMs = 2_000;
|
||||
|
||||
export function subscribe(after: number, onEvent: (ev: ByreEvent) => void, onState: (connected: boolean) => void): void {
|
||||
let last = after;
|
||||
let heard = Date.now();
|
||||
let source: EventSource | undefined;
|
||||
let retry = 0;
|
||||
|
||||
const connect = () => {
|
||||
clearTimeout(retry);
|
||||
source?.close();
|
||||
heard = Date.now();
|
||||
const s = new EventSource(`/api/events?after=${last}`);
|
||||
source = s;
|
||||
s.onopen = () => {
|
||||
heard = Date.now();
|
||||
onState(true);
|
||||
};
|
||||
s.onerror = () => {
|
||||
onState(false);
|
||||
if (s.readyState === EventSource.CLOSED) {
|
||||
retry = setTimeout(connect, retryAfterMs);
|
||||
}
|
||||
};
|
||||
s.addEventListener("ping", () => {
|
||||
heard = Date.now();
|
||||
});
|
||||
s.onmessage = (msg: MessageEvent<string>) => {
|
||||
heard = Date.now();
|
||||
const ev = JSON.parse(msg.data) as ByreEvent;
|
||||
if (ev.seq > last) {
|
||||
last = ev.seq;
|
||||
}
|
||||
onEvent(ev);
|
||||
};
|
||||
};
|
||||
|
||||
const check = () => {
|
||||
if (Date.now() - heard < staleAfterMs) {
|
||||
return;
|
||||
}
|
||||
onState(false);
|
||||
connect();
|
||||
};
|
||||
|
||||
setInterval(check, 5_000);
|
||||
addEventListener("online", connect);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
check();
|
||||
}
|
||||
});
|
||||
connect();
|
||||
}
|
||||
|
||||
+70
-29
@@ -1,6 +1,7 @@
|
||||
import { h, replace } from "./dom.js";
|
||||
import type { View } from "./panes.js";
|
||||
import { activeTasks, findTask, onEach, state } from "./state.js";
|
||||
import { post } from "./api.js";
|
||||
import { errorText, h, replace } from "./dom.js";
|
||||
import { openTranscript, type View } from "./panes.js";
|
||||
import { activeTasks, currentSession, findTask, onEach, state } from "./state.js";
|
||||
|
||||
export function mountNavbar(root: HTMLElement, show: (view: View) => void): void {
|
||||
const crumb = h("button", { class: "crumb", title: "Switch task (⌘K)" });
|
||||
@@ -13,6 +14,7 @@ export function mountNavbar(root: HTMLElement, show: (view: View) => void): void
|
||||
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 = () => {
|
||||
@@ -26,11 +28,12 @@ export function mountNavbar(root: HTMLElement, show: (view: View) => void): void
|
||||
|
||||
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) => {
|
||||
@@ -46,14 +49,26 @@ function openSwitcher(): void {
|
||||
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: ReturnType<typeof activeTasks> = [];
|
||||
let rows: { el: HTMLElement; pick: () => void }[] = [];
|
||||
|
||||
const offs = onEach(["projects", "tasks", "sessions", "inbox"], () => render());
|
||||
const onEscape = (e: KeyboardEvent) => {
|
||||
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();
|
||||
};
|
||||
@@ -61,24 +76,54 @@ function openSwitcher(): void {
|
||||
const go = (taskId: string) => {
|
||||
close();
|
||||
location.hash = `#/task/${taskId}`;
|
||||
openTranscript();
|
||||
};
|
||||
|
||||
const create = async (projectId: string, title: string) => {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
go(task.id);
|
||||
};
|
||||
return li;
|
||||
}),
|
||||
matches.length === 0 && h("li", { class: "empty-row" }, "No matching tasks."),
|
||||
);
|
||||
const title = input.value.trim();
|
||||
const q = title.toLowerCase();
|
||||
const inbox = (taskId: string) => 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();
|
||||
row.pick();
|
||||
};
|
||||
});
|
||||
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 = () => {
|
||||
@@ -86,21 +131,17 @@ function openSwitcher(): void {
|
||||
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") {
|
||||
e.preventDefault();
|
||||
selected = Math.max(selected - 1, 0);
|
||||
render();
|
||||
} else if (e.key === "Enter") {
|
||||
const match = matches[selected];
|
||||
if (match) {
|
||||
go(match.task.id);
|
||||
}
|
||||
e.preventDefault();
|
||||
rows[selected]?.pick();
|
||||
}
|
||||
};
|
||||
backdrop.onclick = close;
|
||||
|
||||
@@ -19,6 +19,15 @@ const views: Record<View, { label: string; mount: (root: HTMLElement) => () => v
|
||||
|
||||
const order: View[] = ["transcript", "changes", "tasks", "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 class Pane {
|
||||
readonly el: HTMLElement;
|
||||
private readonly tabs: HTMLElement;
|
||||
@@ -34,6 +43,11 @@ export class Pane {
|
||||
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 {
|
||||
|
||||
+7
-7
@@ -191,36 +191,36 @@ export function mountTranscript(root: HTMLElement): () => void {
|
||||
replace(run.group.summary, runSummary(run.calls));
|
||||
};
|
||||
|
||||
let stream: { el: HTMLElement; text: string; frame: number } | undefined;
|
||||
let stream: { el: HTMLElement; text: string; timer: number } | undefined;
|
||||
const dropStream = () => {
|
||||
if (!stream) {
|
||||
return;
|
||||
}
|
||||
cancelAnimationFrame(stream.frame);
|
||||
clearTimeout(stream.timer);
|
||||
stream.el.remove();
|
||||
stream = undefined;
|
||||
};
|
||||
const appendDelta = (ev: ByreEvent) => {
|
||||
if (!stream) {
|
||||
stream = { el: h("div", { class: "msg agent" }), text: "", frame: 0 };
|
||||
stream = { el: h("div", { class: "msg agent" }), text: "", timer: 0 };
|
||||
ctx.run = undefined;
|
||||
put(stream.el);
|
||||
}
|
||||
stream.text += String(ev.payload["text"] ?? "");
|
||||
if (stream.frame) {
|
||||
if (stream.timer) {
|
||||
return;
|
||||
}
|
||||
stream.frame = requestAnimationFrame(() => {
|
||||
stream.timer = setTimeout(() => {
|
||||
if (!stream) {
|
||||
return;
|
||||
}
|
||||
stream.frame = 0;
|
||||
stream.timer = 0;
|
||||
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
|
||||
replace(stream.el, markdown(stream.text, "text md"));
|
||||
if (pinned) {
|
||||
transcript.scrollTop = transcript.scrollHeight;
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const foldTurn = (start: HTMLElement, end: HTMLElement) => {
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import { post, type Project } from "./api.js";
|
||||
import { errorText, h, replace } from "./dom.js";
|
||||
import { openTranscript } from "./panes.js";
|
||||
import { currentSession, onEach, state } from "./state.js";
|
||||
|
||||
export function mountTasks(root: HTMLElement): () => void {
|
||||
@@ -27,7 +28,7 @@ function projectBlock(project: Project): HTMLElement {
|
||||
{ class: task.id === state.selectedTaskId ? "selected" : "" },
|
||||
h(
|
||||
"a",
|
||||
{ href: `#/task/${task.id}` },
|
||||
{ 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"),
|
||||
|
||||
Reference in New Issue
Block a user