2026-10-15 09:40:30 -07:00
|
|
|
import { get, put, subscribe } from "./api.js";
|
2026-10-15 20:01:53 -07:00
|
|
|
import { notifyInbox, notifyTurn } from "./notifications.js";
|
2026-10-16 11:01:53 -07:00
|
|
|
export const tabs = ["transcript", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"];
|
2026-10-14 09:40:12 -07:00
|
|
|
export const state = {
|
|
|
|
|
me: undefined,
|
|
|
|
|
projects: [],
|
|
|
|
|
tasks: new Map(),
|
|
|
|
|
sessions: new Map(),
|
|
|
|
|
inbox: [],
|
|
|
|
|
selectedTaskId: undefined,
|
|
|
|
|
transcript: [],
|
2026-10-15 00:08:32 -07:00
|
|
|
pinned: undefined,
|
2026-10-15 17:46:41 -07:00
|
|
|
reviewed: undefined,
|
2026-10-14 09:40:12 -07:00
|
|
|
connected: false,
|
2026-10-15 09:40:30 -07:00
|
|
|
focus: { tabs: ["transcript"], tab: "transcript" },
|
|
|
|
|
leadSessions: [],
|
|
|
|
|
leadTranscript: [],
|
2026-10-14 09:40:12 -07:00
|
|
|
};
|
|
|
|
|
const listeners = new Map();
|
|
|
|
|
export function on(topic, fn) {
|
|
|
|
|
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, fn) {
|
|
|
|
|
const offs = topics.map((t) => on(t, fn));
|
|
|
|
|
return () => {
|
|
|
|
|
for (const off of offs) {
|
|
|
|
|
off();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
export function activeTasks() {
|
|
|
|
|
return state.projects.flatMap((project) => (state.tasks.get(project.id) ?? []).filter((t) => t.status === "active").map((task) => ({ project, task })));
|
|
|
|
|
}
|
|
|
|
|
export function notify(topic, detail) {
|
|
|
|
|
for (const fn of listeners.get(topic) ?? []) {
|
|
|
|
|
fn(detail);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
export function findTask(id) {
|
|
|
|
|
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) {
|
|
|
|
|
return state.sessions.get(taskId)?.findLast((s) => s.state !== "completed") ?? state.sessions.get(taskId)?.at(-1);
|
|
|
|
|
}
|
2026-10-16 08:37:24 -07:00
|
|
|
export function taskState(task) {
|
2026-10-16 10:59:46 -07:00
|
|
|
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" };
|
2026-10-16 10:08:08 -07:00
|
|
|
}
|
|
|
|
|
if (task.merging) {
|
2026-10-16 10:59:46 -07:00
|
|
|
return { kind: "merging", label: "Merging", title: "The merge turn is running" };
|
2026-10-16 10:08:08 -07:00
|
|
|
}
|
2026-10-16 10:59:46 -07:00
|
|
|
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 (open.length > 0) {
|
|
|
|
|
return { kind: "error", label: "Error", title: "The agent's session failed or hit a rate limit; see the inbox" };
|
|
|
|
|
}
|
|
|
|
|
if (task.discarding || task.reviewing || currentSession(task.id)?.state === "running") {
|
|
|
|
|
return { kind: "working", label: "Working", title: task.discarding ? "Discarding" : task.reviewing ? "A review is running" : "The agent is mid-turn" };
|
|
|
|
|
}
|
|
|
|
|
return { kind: "idle", label: "Idle", title: "Waiting for a message" };
|
2026-10-16 08:37:24 -07:00
|
|
|
}
|
2026-10-15 09:40:30 -07:00
|
|
|
export function currentLead() {
|
|
|
|
|
return state.leadSessions.findLast((s) => s.state !== "completed") ?? state.leadSessions.at(-1);
|
|
|
|
|
}
|
2026-10-15 00:08:32 -07:00
|
|
|
export function snapshots() {
|
|
|
|
|
return state.transcript.filter((e) => e.kind === "snapshot.created").map((e) => e.payload);
|
|
|
|
|
}
|
|
|
|
|
export function pinSnapshot(n) {
|
|
|
|
|
state.pinned = n;
|
|
|
|
|
notify("pin");
|
|
|
|
|
}
|
2026-10-15 17:46:41 -07:00
|
|
|
export function pickReview(n) {
|
|
|
|
|
state.reviewed = n;
|
|
|
|
|
notify("review");
|
|
|
|
|
}
|
|
|
|
|
export function showReview(n) {
|
|
|
|
|
pickReview(n);
|
|
|
|
|
focusTab("reviews");
|
|
|
|
|
}
|
2026-10-14 09:40:12 -07:00
|
|
|
export async function loadMe() {
|
|
|
|
|
state.me = await get("/api/me");
|
|
|
|
|
notify("me");
|
|
|
|
|
}
|
|
|
|
|
export async function loadProjects() {
|
|
|
|
|
state.projects = await get("/api/projects");
|
|
|
|
|
await Promise.all(state.projects.map((p) => loadTasks(p.id)));
|
|
|
|
|
notify("projects");
|
|
|
|
|
}
|
|
|
|
|
export async function loadTasks(projectId) {
|
|
|
|
|
const tasks = await get("/api/projects/{projectId}/tasks", { path: { projectId } });
|
|
|
|
|
state.tasks.set(projectId, tasks);
|
2026-10-16 10:45:38 -07:00
|
|
|
for (const t of tasks) {
|
|
|
|
|
if (t.status === "active") {
|
|
|
|
|
taskFeed(t.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
dropClosedFeeds();
|
2026-10-14 09:40:12 -07:00
|
|
|
await Promise.all(tasks.filter((t) => t.status === "active").map((t) => loadSessions(t.id)));
|
|
|
|
|
notify("tasks");
|
|
|
|
|
}
|
|
|
|
|
export async function loadSessions(taskId) {
|
|
|
|
|
state.sessions.set(taskId, await get("/api/tasks/{taskId}/sessions", { path: { taskId } }));
|
|
|
|
|
notify("sessions", taskId);
|
|
|
|
|
}
|
|
|
|
|
export async function loadInbox() {
|
|
|
|
|
state.inbox = await get("/api/inbox");
|
|
|
|
|
notify("inbox");
|
|
|
|
|
}
|
2026-10-15 09:40:30 -07:00
|
|
|
export async function loadLeadSessions() {
|
|
|
|
|
state.leadSessions = await get("/api/me/lead/sessions");
|
|
|
|
|
notify("lead.sessions");
|
|
|
|
|
}
|
|
|
|
|
export async function loadFocus() {
|
|
|
|
|
applyFocus(await get("/api/me/focus"));
|
|
|
|
|
}
|
|
|
|
|
export function applyFocus(f) {
|
|
|
|
|
state.focus = f;
|
2026-10-15 09:59:13 -07:00
|
|
|
const hash = f.task_id ? `#/task/${f.task_id}` : f.project_id ? `#/project/${f.project_id}` : "#/";
|
2026-10-15 09:40:30 -07:00
|
|
|
if (location.hash !== hash && !(hash === "#/" && location.hash === "")) {
|
|
|
|
|
location.hash = hash;
|
|
|
|
|
}
|
|
|
|
|
notify("focus");
|
|
|
|
|
}
|
2026-10-15 09:59:13 -07:00
|
|
|
export function focusedProject() {
|
|
|
|
|
return state.projects.find((p) => p.id === state.focus.project_id);
|
|
|
|
|
}
|
2026-10-15 09:40:30 -07:00
|
|
|
export async function setFocus(change) {
|
|
|
|
|
const taskId = change.task_id === undefined ? state.focus.task_id : (change.task_id ?? undefined);
|
2026-10-15 09:59:13 -07:00
|
|
|
const projectId = taskId ? findTask(taskId)?.project_id : change.project_id === undefined ? state.focus.project_id : (change.project_id ?? undefined);
|
2026-10-15 09:40:30 -07:00
|
|
|
const next = { tabs: state.focus.tabs, tab: change.tab ?? state.focus.tab };
|
|
|
|
|
if (taskId) {
|
|
|
|
|
next.task_id = taskId;
|
|
|
|
|
}
|
2026-10-15 09:59:13 -07:00
|
|
|
if (projectId) {
|
|
|
|
|
next.project_id = projectId;
|
|
|
|
|
}
|
2026-10-15 09:40:30 -07:00
|
|
|
applyFocus(next);
|
|
|
|
|
applyFocus(await put("/api/me/focus", { body: next }));
|
|
|
|
|
}
|
|
|
|
|
export function focusTask(taskId, tab = "transcript") {
|
|
|
|
|
setFocus({ task_id: taskId, tab }).catch((err) => console.error("set focus", err));
|
|
|
|
|
}
|
2026-10-15 09:59:13 -07:00
|
|
|
export function focusProject(projectId) {
|
2026-10-15 10:36:32 -07:00
|
|
|
setFocus({ task_id: null, project_id: projectId, tab: "project" }).catch((err) => console.error("set focus", err));
|
2026-10-15 09:59:13 -07:00
|
|
|
}
|
2026-10-15 09:40:30 -07:00
|
|
|
export function focusTab(tab) {
|
|
|
|
|
setFocus({ tab }).catch((err) => console.error("set focus", err));
|
|
|
|
|
}
|
|
|
|
|
class Feed {
|
|
|
|
|
transcriptTopic;
|
|
|
|
|
streamTopic;
|
2026-10-16 10:45:38 -07:00
|
|
|
readyTopic;
|
|
|
|
|
shown;
|
2026-10-15 09:40:30 -07:00
|
|
|
events = [];
|
2026-10-16 10:45:38 -07:00
|
|
|
ready = false;
|
2026-10-15 09:40:30 -07:00
|
|
|
last = 0;
|
|
|
|
|
buffer = [];
|
|
|
|
|
timer = 0;
|
2026-10-16 10:45:38 -07:00
|
|
|
constructor(transcriptTopic, streamTopic, readyTopic, shown) {
|
2026-10-15 09:40:30 -07:00
|
|
|
this.transcriptTopic = transcriptTopic;
|
|
|
|
|
this.streamTopic = streamTopic;
|
2026-10-16 10:45:38 -07:00
|
|
|
this.readyTopic = readyTopic;
|
|
|
|
|
this.shown = shown;
|
2026-10-15 09:40:30 -07:00
|
|
|
}
|
|
|
|
|
push = (ev) => {
|
2026-10-16 10:45:38 -07:00
|
|
|
if (ev.seq === 0 && (!this.ready || !this.shown())) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-15 09:40:30 -07:00
|
|
|
this.buffer.push(ev);
|
2026-10-16 10:45:38 -07:00
|
|
|
if (this.ready && !this.timer) {
|
2026-10-15 09:40:30 -07:00
|
|
|
this.timer = setTimeout(() => this.flush(), 0);
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-10-16 10:45:38 -07:00
|
|
|
load = (history) => {
|
|
|
|
|
if (this.ready) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.ready = true;
|
|
|
|
|
this.buffer = [...history, ...this.buffer];
|
|
|
|
|
this.flush();
|
|
|
|
|
if (this.shown()) {
|
|
|
|
|
notify(this.readyTopic);
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-10-15 09:40:30 -07:00
|
|
|
flush() {
|
2026-10-16 10:45:38 -07:00
|
|
|
clearTimeout(this.timer);
|
2026-10-15 09:40:30 -07:00
|
|
|
const events = this.buffer;
|
|
|
|
|
this.buffer = [];
|
|
|
|
|
this.timer = 0;
|
|
|
|
|
let stored = [];
|
|
|
|
|
for (const ev of events) {
|
|
|
|
|
if (ev.seq !== 0) {
|
|
|
|
|
stored.push(ev);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
this.append(stored);
|
|
|
|
|
stored = [];
|
2026-10-16 10:45:38 -07:00
|
|
|
if (this.shown()) {
|
|
|
|
|
notify(this.streamTopic, ev);
|
|
|
|
|
}
|
2026-10-15 09:40:30 -07:00
|
|
|
}
|
|
|
|
|
this.append(stored);
|
|
|
|
|
}
|
|
|
|
|
append(events) {
|
2026-10-16 10:45:38 -07:00
|
|
|
const fresh = [];
|
|
|
|
|
for (const ev of events) {
|
|
|
|
|
if (ev.seq > this.last) {
|
|
|
|
|
fresh.push(ev);
|
|
|
|
|
this.last = ev.seq;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-15 09:40:30 -07:00
|
|
|
if (fresh.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.events.push(...fresh);
|
2026-10-16 10:45:38 -07:00
|
|
|
if (this.shown()) {
|
|
|
|
|
notify(this.transcriptTopic, fresh);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const historyPage = 1000;
|
|
|
|
|
const taskFeeds = new Map();
|
|
|
|
|
const leadFeed = new Feed("lead.transcript", "lead.stream", "lead.ready", () => true);
|
|
|
|
|
state.leadTranscript = leadFeed.events;
|
|
|
|
|
async function loadHistory(taskId, feed) {
|
|
|
|
|
const events = [];
|
|
|
|
|
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();
|
|
|
|
|
function taskFeed(taskId) {
|
|
|
|
|
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) => console.error("load task history", taskId, err))
|
|
|
|
|
.finally(() => historyLoads.delete(loading));
|
|
|
|
|
return feed;
|
|
|
|
|
}
|
|
|
|
|
function dropClosedFeeds() {
|
|
|
|
|
for (const taskId of taskFeeds.keys()) {
|
|
|
|
|
const task = findTask(taskId);
|
|
|
|
|
if (taskId !== state.selectedTaskId && task && task.status !== "active") {
|
|
|
|
|
taskFeeds.delete(taskId);
|
|
|
|
|
}
|
2026-10-15 09:40:30 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-16 10:45:38 -07:00
|
|
|
export function transcriptLoading() {
|
|
|
|
|
return state.selectedTaskId !== undefined && taskFeeds.get(state.selectedTaskId)?.ready !== true;
|
|
|
|
|
}
|
|
|
|
|
export function leadLoading() {
|
|
|
|
|
return !leadFeed.ready;
|
|
|
|
|
}
|
2026-10-14 23:23:25 -07:00
|
|
|
const visitsKey = "byre.visits";
|
|
|
|
|
export function lastVisits() {
|
|
|
|
|
return JSON.parse(localStorage.getItem(visitsKey) ?? "{}");
|
|
|
|
|
}
|
|
|
|
|
function markVisited(taskId) {
|
|
|
|
|
if (!taskId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
localStorage.setItem(visitsKey, JSON.stringify({ ...lastVisits(), [taskId]: Date.now() }));
|
|
|
|
|
}
|
2026-10-14 23:53:17 -07:00
|
|
|
export function selectTask(taskId) {
|
2026-10-14 23:23:25 -07:00
|
|
|
markVisited(state.selectedTaskId);
|
|
|
|
|
markVisited(taskId);
|
2026-10-14 09:40:12 -07:00
|
|
|
state.selectedTaskId = taskId;
|
2026-10-16 10:45:38 -07:00
|
|
|
state.transcript = taskId ? taskFeed(taskId).events : [];
|
2026-10-15 00:08:32 -07:00
|
|
|
state.pinned = undefined;
|
2026-10-15 17:46:41 -07:00
|
|
|
state.reviewed = undefined;
|
2026-10-16 10:45:38 -07:00
|
|
|
dropClosedFeeds();
|
2026-10-14 09:40:12 -07:00
|
|
|
notify("selection");
|
2026-10-14 23:53:17 -07:00
|
|
|
}
|
2026-10-15 09:40:30 -07:00
|
|
|
export function startLeadStream() {
|
2026-10-16 10:45:38 -07:00
|
|
|
subscribe(0, { lead: true }, leadFeed.push, () => undefined, () => leadFeed.load([]));
|
2026-10-14 09:40:12 -07:00
|
|
|
}
|
|
|
|
|
export function handleEvent(ev) {
|
2026-10-16 10:45:38 -07:00
|
|
|
if (ev.task_id) {
|
|
|
|
|
taskFeeds.get(ev.task_id)?.push(ev);
|
|
|
|
|
}
|
2026-10-14 19:35:34 -07:00
|
|
|
if (ev.seq === 0) {
|
2026-10-14 22:35:53 -07:00
|
|
|
notify("event", ev);
|
2026-10-14 19:35:34 -07:00
|
|
|
return;
|
|
|
|
|
}
|
2026-10-14 09:40:12 -07:00
|
|
|
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":
|
2026-10-14 21:21:54 -07:00
|
|
|
case "task.merge_title":
|
2026-10-15 12:11:35 -07:00
|
|
|
case "task.merge_queued":
|
|
|
|
|
case "task.merge_unqueued":
|
2026-10-16 10:36:38 -07:00
|
|
|
case "task.parent_merge_started":
|
|
|
|
|
case "task.parent_merge_stopped":
|
2026-10-16 10:08:08 -07:00
|
|
|
case "task.merge_started":
|
|
|
|
|
case "task.merge_ended":
|
|
|
|
|
case "task.discard_started":
|
2026-10-14 19:35:34 -07:00
|
|
|
case "task.merged":
|
|
|
|
|
case "task.discarded":
|
2026-10-14 09:40:12 -07:00
|
|
|
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":
|
2026-10-15 09:40:30 -07:00
|
|
|
if (ev.owner) {
|
|
|
|
|
void loadLeadSessions();
|
|
|
|
|
}
|
|
|
|
|
else if (ev.task_id) {
|
2026-10-14 09:40:12 -07:00
|
|
|
void loadSessions(ev.task_id);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "inbox.created":
|
2026-10-15 18:34:53 -07:00
|
|
|
notifyInbox(ev.payload);
|
|
|
|
|
void loadInbox();
|
|
|
|
|
break;
|
2026-10-14 09:40:12 -07:00
|
|
|
case "inbox.updated":
|
|
|
|
|
void loadInbox();
|
|
|
|
|
break;
|
2026-10-15 20:01:53 -07:00
|
|
|
case "turn.ended":
|
|
|
|
|
notifyTurn(ev);
|
|
|
|
|
break;
|
2026-10-15 16:33:17 -07:00
|
|
|
case "review.started":
|
|
|
|
|
case "review.finished":
|
2026-10-16 08:37:24 -07:00
|
|
|
if (ev.project_id) {
|
|
|
|
|
void loadTasks(ev.project_id);
|
|
|
|
|
}
|
2026-10-15 16:33:17 -07:00
|
|
|
if (state.inbox.some((i) => i.task_id === ev.task_id && i.kind === "question")) {
|
|
|
|
|
void loadInbox();
|
|
|
|
|
}
|
|
|
|
|
break;
|
2026-10-16 10:44:27 -07:00
|
|
|
case "review.finding":
|
|
|
|
|
if (state.inbox.some((i) => i.task_id === ev.task_id && i.kind === "question")) {
|
|
|
|
|
void loadInbox();
|
|
|
|
|
}
|
|
|
|
|
break;
|
2026-10-15 09:40:30 -07:00
|
|
|
case "focus.updated":
|
|
|
|
|
if (ev.actor !== state.me?.login) {
|
|
|
|
|
applyFocus(ev.payload);
|
|
|
|
|
}
|
|
|
|
|
break;
|
2026-10-14 09:40:12 -07:00
|
|
|
}
|
2026-10-14 21:06:30 -07:00
|
|
|
notify("event", ev);
|
2026-10-14 09:40:12 -07:00
|
|
|
}
|
|
|
|
|
//# sourceMappingURL=state.js.map
|