Files
byre/web/src/state.ts
T

558 lines
16 KiB
TypeScript

import { get, put, subscribe, type AgentSession, type ByreEvent, type Focus, type InboxItem, type Project, type Schemas, type Snapshot, type Task, type TaskTab, type User } from "./api.js";
import { notifyInbox, notifyTurn } from "./notifications.js";
export type Topic =
| "me"
| "projects"
| "tasks"
| "sessions"
| "inbox"
| "selection"
| "transcript"
| "stream"
| "connection"
| "event"
| "pin"
| "review"
| "focus"
| "lead.sessions"
| "lead.transcript"
| "lead.stream"
| "ready"
| "lead.ready"
| "summaries"
| "jump";
export const tabs: TaskTab[] = ["transcript", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"];
export const state = {
me: undefined as User | undefined,
projects: [] as Project[],
tasks: new Map<string, Task[]>(),
sessions: new Map<string, AgentSession[]>(),
inbox: [] as InboxItem[],
selectedTaskId: undefined as string | undefined,
transcript: [] as ByreEvent[],
pinned: undefined as number | undefined,
reviewed: undefined as number | undefined,
connected: false,
focus: { tabs: ["transcript"], tab: "transcript" } as Focus,
leadSessions: [] as AgentSession[],
leadTranscript: [] as ByreEvent[],
jump: undefined as { taskId: string; seq: number } | undefined,
summaryReads: {} as Record<string, number>,
};
const listeners = new Map<Topic, Set<(detail?: unknown) => void>>();
export function on(topic: Topic, fn: (detail?: unknown) => void): () => void {
let set = listeners.get(topic);
if (!set) {
set = new Set();
listeners.set(topic, set);
}
set.add(fn);
return () => set.delete(fn);
}
export function onEach(topics: Topic[], fn: (detail?: unknown) => void): () => void {
const offs = topics.map((t) => on(t, fn));
return () => {
for (const off of offs) {
off();
}
};
}
export function activeTasks(): { project: Project; task: Task }[] {
return state.projects.flatMap((project) => (state.tasks.get(project.id) ?? []).filter((t) => t.status === "active").map((task) => ({ project, task })));
}
export function notify(topic: Topic, detail?: unknown): void {
for (const fn of listeners.get(topic) ?? []) {
fn(detail);
}
}
export function findTask(id: string): Task | undefined {
for (const tasks of state.tasks.values()) {
const task = tasks.find((t) => t.id === id);
if (task) {
return task;
}
}
return undefined;
}
export function currentSession(taskId: string): AgentSession | undefined {
return state.sessions.get(taskId)?.findLast((s) => s.state !== "completed") ?? state.sessions.get(taskId)?.at(-1);
}
const workingTitles: Partial<Record<Task["state"], string>> = {
running: "The agent is mid-turn or its subagents are running",
review_pending: "A review of the turn's work is about to start",
reviewing: "A review is running",
discarding: "Discarding",
};
export type TaskState = { kind: "answer" | "merging" | "held" | "error" | "working" | "idle"; label: string; title: string };
export function taskState(task: Task): TaskState {
const open = state.inbox.filter((item) => item.task_id === task.id);
if (open.some((item) => item.kind === "question")) {
return { kind: "answer", label: "Needs your answer", title: "The agent asked a question in the inbox" };
}
if (task.merging) {
return { kind: "merging", label: "Merging", title: "The merge turn is running" };
}
if (task.merging_into_parent) {
return { kind: "merging", label: "Merging", title: "Waiting for the parent task's agent to merge it" };
}
if (task.merge_queued_at !== undefined) {
return { kind: "held", label: "Merge held", title: "Waiting for the merge in progress in this project to finish" };
}
if (task.merge_after_review) {
return { kind: "held", label: "Merge after review", title: "Merges when the running review finishes with no findings" };
}
if (open.length > 0) {
return { kind: "error", label: "Error", title: "The agent's session failed or hit a rate limit; see the inbox" };
}
const working = workingTitles[task.state];
if (working !== undefined) {
return { kind: "working", label: "Working", title: working };
}
return { kind: "idle", label: "Idle", title: "Waiting for a message" };
}
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);
}
export function pinSnapshot(n: number | undefined): void {
state.pinned = n;
notify("pin");
}
export function pickReview(n: number): void {
state.reviewed = n;
notify("review");
}
export function showReview(n: number): void {
pickReview(n);
focusTab("reviews");
}
export async function loadMe(): Promise<void> {
state.me = await get("/api/me");
notify("me");
}
export async function loadProjects(): Promise<void> {
state.projects = await get("/api/projects");
await Promise.all(state.projects.map((p) => loadTasks(p.id)));
notify("projects");
}
const requestSeqs = new Map<string, number>();
function claimRequest(key: string): () => boolean {
const seq = (requestSeqs.get(key) ?? 0) + 1;
requestSeqs.set(key, seq);
return () => requestSeqs.get(key) === seq;
}
export async function loadTasks(projectId: string): Promise<void> {
const latest = claimRequest(`tasks:${projectId}`);
const tasks = await get("/api/projects/{projectId}/tasks", { path: { projectId } });
if (!latest()) {
return;
}
state.tasks.set(projectId, tasks);
for (const t of tasks) {
if (t.status === "active") {
taskFeed(t.id);
}
}
dropClosedFeeds();
await Promise.all(tasks.filter((t) => t.status === "active").map((t) => loadSessions(t.id)));
notify("tasks");
}
export async function loadSessions(taskId: string): Promise<void> {
const latest = claimRequest(`sessions:${taskId}`);
const sessions = await get("/api/tasks/{taskId}/sessions", { path: { taskId } });
if (!latest()) {
return;
}
state.sessions.set(taskId, sessions);
notify("sessions", taskId);
}
export async function loadInbox(): Promise<void> {
const latest = claimRequest("inbox");
const inbox = await get("/api/inbox");
if (!latest()) {
return;
}
state.inbox = inbox;
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> {
const latest = claimRequest("focus");
const focus = await get("/api/me/focus");
if (!latest()) {
return;
}
applyFocus(focus);
}
export function applyFocus(f: Focus): void {
state.focus = f;
const hash = f.task_id ? `#/task/${f.task_id}` : f.project_id ? `#/project/${f.project_id}` : "#/";
if (location.hash !== hash && !(hash === "#/" && location.hash === "")) {
location.hash = hash;
}
notify("focus");
}
export function focusedProject(): Project | undefined {
return state.projects.find((p) => p.id === state.focus.project_id);
}
export async function setFocus(change: { task_id?: string | null; project_id?: string | null; tab?: TaskTab }): Promise<void> {
const taskId = change.task_id === undefined ? state.focus.task_id : (change.task_id ?? undefined);
const projectId = taskId ? findTask(taskId)?.project_id : change.project_id === undefined ? state.focus.project_id : (change.project_id ?? undefined);
const next: Focus = { tabs: state.focus.tabs, tab: change.tab ?? state.focus.tab };
if (taskId) {
next.task_id = taskId;
}
if (projectId) {
next.project_id = projectId;
}
const latest = claimRequest("focus");
applyFocus(next);
const focus = await put("/api/me/focus", { body: next });
if (!latest()) {
return;
}
applyFocus(focus);
}
export function focusTask(taskId: string, tab: TaskTab = "transcript"): void {
setFocus({ task_id: taskId, tab }).catch((err: unknown) => console.error("set focus", err));
}
export function focusProject(projectId: string): void {
setFocus({ task_id: null, project_id: projectId, tab: "project" }).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 {
readonly events: ByreEvent[] = [];
ready = false;
private last = 0;
private buffer: ByreEvent[] = [];
private timer = 0;
constructor(
private readonly transcriptTopic: Topic,
private readonly streamTopic: Topic,
private readonly readyTopic: Topic,
private readonly shown: () => boolean,
) {}
readonly push = (ev: ByreEvent): void => {
if (ev.seq === 0 && (!this.ready || !this.shown())) {
return;
}
this.buffer.push(ev);
if (this.ready && !this.timer) {
this.timer = setTimeout(() => this.flush(), 0);
}
};
readonly load = (history: ByreEvent[]): void => {
if (this.ready) {
return;
}
this.ready = true;
this.buffer = [...history, ...this.buffer];
this.flush();
if (this.shown()) {
notify(this.readyTopic);
}
};
private flush(): void {
clearTimeout(this.timer);
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 = [];
if (this.shown()) {
notify(this.streamTopic, ev);
}
}
this.append(stored);
}
private append(events: ByreEvent[]): void {
const fresh: ByreEvent[] = [];
for (const ev of events) {
if (ev.seq > this.last) {
fresh.push(ev);
this.last = ev.seq;
}
}
if (fresh.length === 0) {
return;
}
this.events.push(...fresh);
if (this.shown()) {
notify(this.transcriptTopic, fresh);
}
}
}
const historyPage = 1000;
const taskFeeds = new Map<string, Feed>();
const leadFeed = new Feed("lead.transcript", "lead.stream", "lead.ready", () => true);
state.leadTranscript = leadFeed.events;
async function loadHistory(taskId: string, feed: Feed): Promise<void> {
const events: ByreEvent[] = [];
for (;;) {
const page = await get("/api/tasks/{taskId}/events", { path: { taskId }, query: { after: events.at(-1)?.seq ?? 0, limit: historyPage } });
events.push(...page);
if (page.length < historyPage) {
break;
}
}
feed.load(events);
}
const historyLoads = new Set<Feed>();
function taskFeed(taskId: string): Feed {
let feed = taskFeeds.get(taskId);
if (!feed) {
feed = new Feed("transcript", "stream", "ready", () => state.selectedTaskId === taskId);
taskFeeds.set(taskId, feed);
}
if (feed.ready || historyLoads.has(feed)) {
return feed;
}
const loading = feed;
historyLoads.add(loading);
loadHistory(taskId, loading)
.catch((err: unknown) => console.error("load task history", taskId, err))
.finally(() => historyLoads.delete(loading));
return feed;
}
function dropClosedFeeds(): void {
for (const taskId of taskFeeds.keys()) {
const task = findTask(taskId);
if (taskId !== state.selectedTaskId && task && task.status !== "active") {
taskFeeds.delete(taskId);
}
}
}
export function transcriptLoading(): boolean {
return state.selectedTaskId !== undefined && taskFeeds.get(state.selectedTaskId)?.ready !== true;
}
export function leadLoading(): boolean {
return !leadFeed.ready;
}
const visitsKey = "byre.visits";
export function lastVisits(): Record<string, number> {
return JSON.parse(localStorage.getItem(visitsKey) ?? "{}") as Record<string, number>;
}
function markVisited(taskId: string | undefined): void {
if (!taskId) {
return;
}
localStorage.setItem(visitsKey, JSON.stringify({ ...lastVisits(), [taskId]: Date.now() }));
}
export async function loadSummaryReads(): Promise<void> {
state.summaryReads = await get("/api/me/summary-reads");
notify("summaries");
}
export function unreadSummary(task: Task): boolean {
return task.workflow === undefined && task.summary_seq > (state.summaryReads[task.id] ?? 0);
}
function noteSummaryRead(taskId: string, seq: number): boolean {
if ((state.summaryReads[taskId] ?? 0) >= seq) {
return false;
}
state.summaryReads[taskId] = seq;
notify("summaries");
return true;
}
export function markSummaryRead(taskId: string, seq: number): void {
if (!noteSummaryRead(taskId, seq)) {
return;
}
put("/api/tasks/{taskId}/summary-read", { path: { taskId }, body: { seq } }).catch((err: unknown) => console.error("mark summary read", err));
}
export function openSummary(task: Task): void {
state.jump = { taskId: task.id, seq: task.summary_seq };
focusTask(task.id, "transcript");
notify("jump");
}
export function selectTask(taskId: string | undefined): void {
markVisited(state.selectedTaskId);
markVisited(taskId);
state.selectedTaskId = taskId;
state.transcript = taskId ? taskFeed(taskId).events : [];
state.pinned = undefined;
state.reviewed = undefined;
dropClosedFeeds();
notify("selection");
}
const leadPage = 50;
let leadMore = false;
async function fetchLeadTail(): Promise<Schemas["EventPage"]> {
for (;;) {
try {
return await get("/api/me/lead/events", { query: { turns: leadPage } });
} catch (err) {
console.error("load lead history", err);
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
}
}
export async function startLeadStream(): Promise<void> {
const page = await fetchLeadTail();
leadMore = page.more;
subscribe(page.events.at(-1)?.seq ?? 0, { lead: true }, leadFeed.push, () => undefined, () => leadFeed.load(page.events));
}
export function leadHasOlder(): boolean {
return leadFeed.ready && leadMore;
}
export async function loadOlderLead(): Promise<ByreEvent[]> {
const first = leadFeed.events[0];
if (!first) {
return [];
}
const page = await get("/api/me/lead/events", { query: { before: first.seq, turns: leadPage } });
leadMore = page.more;
leadFeed.events.unshift(...page.events);
return page.events;
}
export function handleEvent(ev: ByreEvent): void {
if (ev.task_id) {
taskFeeds.get(ev.task_id)?.push(ev);
}
if (ev.seq === 0) {
notify("event", ev);
return;
}
switch (ev.kind) {
case "user.updated":
if (ev.payload["login"] === state.me?.login) {
void loadMe();
}
break;
case "project.created":
void loadProjects();
break;
case "task.created":
case "task.merge_title":
case "task.merge_queued":
case "task.merge_unqueued":
case "task.merge_after_review":
case "task.merge_after_review_ended":
case "task.merge_after_review_cancelled":
case "task.parent_merge_started":
case "task.parent_merge_stopped":
case "task.merge_started":
case "task.merge_ended":
case "task.discard_started":
case "task.merged":
case "task.discarded":
case "task.state":
if (ev.project_id) {
void loadTasks(ev.project_id);
}
break;
case "session.created":
case "session.state":
case "session.subagents":
case "session.model":
if (ev.owner) {
void loadLeadSessions();
} else if (ev.task_id) {
void loadSessions(ev.task_id);
}
break;
case "inbox.created":
notifyInbox(ev.payload as unknown as InboxItem);
void loadInbox();
break;
case "inbox.updated":
void loadInbox();
break;
case "turn.ended": {
notifyTurn(ev);
const task = ev.payload["summary"] === true && ev.task_id ? findTask(ev.task_id) : undefined;
if (task && ev.seq > task.summary_seq) {
task.summary_seq = ev.seq;
notify("summaries");
}
break;
}
case "summary.read":
noteSummaryRead(String(ev.payload["task_id"]), Number(ev.payload["seq"]));
break;
case "focus.updated":
if (ev.actor !== state.me?.login) {
claimRequest("focus");
applyFocus(ev.payload as unknown as Focus);
}
break;
}
notify("event", ev);
}