Files
byre/web/src/state.ts
T

210 lines
5.5 KiB
TypeScript
Raw Normal View History

2026-10-15 00:08:32 -07:00
import { get, subscribe, type AgentSession, type ByreEvent, type InboxItem, type Project, type Snapshot, type Task, type User } from "./api.js";
2026-10-15 00:08:32 -07:00
export type Topic = "me" | "projects" | "tasks" | "sessions" | "inbox" | "selection" | "transcript" | "stream" | "connection" | "event" | "pin";
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[],
lastTranscriptSeq: 0,
2026-10-15 00:08:32 -07:00
pinned: undefined as number | undefined,
connected: false,
};
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);
}
2026-10-15 00:08:32 -07:00
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 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");
}
export async function loadTasks(projectId: string): Promise<void> {
const tasks = await get("/api/projects/{projectId}/tasks", { path: { projectId } });
state.tasks.set(projectId, tasks);
await Promise.all(tasks.filter((t) => t.status === "active").map((t) => loadSessions(t.id)));
notify("tasks");
}
export async function loadSessions(taskId: string): Promise<void> {
state.sessions.set(taskId, await get("/api/tasks/{taskId}/sessions", { path: { taskId } }));
notify("sessions", taskId);
}
export async function loadInbox(): Promise<void> {
state.inbox = await get("/api/inbox");
notify("inbox");
}
let closeTaskStream: (() => void) | undefined;
let taskBuffer: ByreEvent[] = [];
let taskFlush = 0;
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 function selectTask(taskId: string | undefined): void {
markVisited(state.selectedTaskId);
markVisited(taskId);
closeTaskStream?.();
closeTaskStream = undefined;
clearTimeout(taskFlush);
taskFlush = 0;
taskBuffer = [];
state.selectedTaskId = taskId;
state.transcript = [];
state.lastTranscriptSeq = 0;
2026-10-15 00:08:32 -07:00
state.pinned = undefined;
notify("selection");
if (!taskId) {
return;
}
closeTaskStream = subscribe(0, taskId, queueTaskEvent, () => 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 handleEvent(ev: ByreEvent): void {
2026-10-14 19:35:34 -07:00
if (ev.seq === 0) {
notify("event", ev);
2026-10-14 19:35:34 -07:00
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":
2026-10-14 19:35:34 -07:00
case "task.merged":
case "task.discarded":
if (ev.project_id) {
void loadTasks(ev.project_id);
}
break;
case "session.created":
case "session.state":
2026-10-14 19:35:34 -07:00
case "session.model":
if (ev.task_id) {
void loadSessions(ev.task_id);
}
break;
case "inbox.created":
case "inbox.updated":
void loadInbox();
break;
}
2026-10-14 21:06:30 -07:00
notify("event", ev);
}