Add concurrency caps, the two-pane web UI, AGENTS.md, and plan status
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import { get, type AgentSession, type ByreEvent, type InboxItem, type Project, type Task, type User } from "./api.js";
|
||||
|
||||
export type Topic = "me" | "projects" | "tasks" | "sessions" | "inbox" | "selection" | "transcript" | "connection";
|
||||
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
export async function selectTask(taskId: string | undefined): Promise<void> {
|
||||
state.selectedTaskId = taskId;
|
||||
state.transcript = [];
|
||||
state.lastTranscriptSeq = 0;
|
||||
notify("selection");
|
||||
|
||||
if (!taskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
const page = await get("/api/tasks/{taskId}/events", { path: { taskId }, query: { after: state.lastTranscriptSeq, limit: 1000 } });
|
||||
if (state.selectedTaskId !== taskId) {
|
||||
return;
|
||||
}
|
||||
appendTranscript(page);
|
||||
if (page.length < 1000) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if (ev.task_id && ev.task_id === state.selectedTaskId) {
|
||||
appendTranscript([ev]);
|
||||
}
|
||||
|
||||
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.completed":
|
||||
if (ev.project_id) {
|
||||
void loadTasks(ev.project_id);
|
||||
}
|
||||
break;
|
||||
case "session.created":
|
||||
case "session.state":
|
||||
if (ev.task_id) {
|
||||
void loadSessions(ev.task_id);
|
||||
}
|
||||
break;
|
||||
case "inbox.created":
|
||||
case "inbox.updated":
|
||||
void loadInbox();
|
||||
break;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user