This commit is contained in:
Ian Gulliver
2026-10-16 13:16:58 -07:00
parent 060df5b5f8
commit f697689961
4 changed files with 30 additions and 10 deletions
+1 -1
View File
@@ -321,6 +321,6 @@ A workflow is a named graph of stages, each a stage name, the stages it comes af
`GET /api/events` is server-sent events: `id` is the event's `seq`, `data` the Event JSON. Reconnecting with `Last-Event-ID` (EventSource does this itself) or `?after=` replays everything after that seq before going live. `?task_id=` limits the stream, replay and live, to one task's events (404 for an unknown task); `?lead=true` to the events of the caller's lead agent sessions. An event with an `owner` (a lead agent's events, a user's `focus.updated`, `lead.note_saved` and `lead.note_deleted`) goes only to that user's streams; the others never see it. Unstored events (`seq` 0, currently only `agent.delta`) go out live only, without an `id` line, so they don't move `Last-Event-ID`. A client that falls further behind than its subscriber buffer (`subscribe` in `internal/server/hub.go`) is disconnected and catches up by reconnecting. Every 15s the server sends an `event: ping` so clients can tell a live stream from a dead one. Once the replay is written, before anything live, the server sends an `event: ready`.
The page keeps every active task's events in memory, one `Feed` per task (`taskFeeds` in `web/src/state.ts`), so switching tasks makes no request. Each `loadTasks` creates a feed for any active task without one, which pages through `GET /api/tasks/{taskId}/events` (1000 at a time) for its history; selecting a task that has no feed (a closed one) does the same. Live events reach every feed through the page's one global stream, which already carries every task's unowned events; a feed holds back what arrives while its history loads and drops the duplicates by `seq`. Feeds of closed tasks are dropped once they aren't selected. A failed history load is logged to the console and leaves the feed waiting for its history; the next `loadTasks` or selection of that task loads it again into the same feed. A feed takes its history, then the live events held back during the load, skipping each event whose `seq` is at or below the last one it kept, so an event in both is kept once. The lead's feed replays its own `?lead=true` stream and holds back what arrives until that stream's first `ready`. Either way the whole history reaches the transcript at once, so it draws in one pass, already scrolled to the bottom; until then the transcript shows "loading…". The web UI (`subscribe` in `web/src/api.ts`) tracks the last seq it saw and opens a new stream from it when nothing (event or ping) has arrived for 40s, 2s after the browser gives up on one (as it does when `tailscale serve` answers 502 while byre restarts), when the browser comes back online, and on becoming visible if the stream is stale. While disconnected the left pane's tab strip shows a red dot and "reconnecting…"; partial output (`agent.delta`) sent while disconnected is lost, and the finished `agent.message` replaces it.
The page keeps every active task's events in memory, one `Feed` per task (`taskFeeds` in `web/src/state.ts`), so switching tasks makes no request. Each `loadTasks` creates a feed for any active task without one, which pages through `GET /api/tasks/{taskId}/events` (1000 at a time) for its history; selecting a task that has no feed (a closed one) does the same. Live events reach every feed through the page's one global stream, which already carries every task's unowned events; a feed holds back what arrives while its history loads and drops the duplicates by `seq`. Feeds of closed tasks are dropped once they aren't selected. A failed history load is logged to the console and leaves the feed waiting for its history; the next `loadTasks` or selection of that task loads it again into the same feed. A feed takes its history, then the live events held back during the load, skipping each event whose `seq` is at or below the last one it kept, so an event in both is kept once. The lead's feed replays its own `?lead=true` stream and holds back what arrives until that stream's first `ready`. Either way the whole history reaches the transcript at once, so it draws in one pass, already scrolled to the bottom; until then the transcript shows "loading…". The web UI (`subscribe` in `web/src/api.ts`) opens no stream until the window's `load` event has fired, since WebKit counts an EventSource opened earlier as part of the page load and shows its loading indicator in place of the favicon for as long as the stream stays open. It tracks the last seq it saw and opens a new stream from it when nothing (event or ping) has arrived for 40s, 2s after the browser gives up on one (as it does when `tailscale serve` answers 502 while byre restarts), when the browser comes back online, and on becoming visible if the stream is stale. While disconnected the left pane's tab strip shows a red dot and "reconnecting…"; partial output (`agent.delta`) sent while disconnected is lost, and the finished `agent.message` replaces it.
The web UI keeps three streams: one for all events from the page's load onward, which drives the task, session, inbox and lead session lists, the right pane's focus and the connection dot; one per selected task (`selectTask` in `web/src/state.ts`), opened with `?task_id=` and `after=0` so it replays the task's whole history and then stays live; and one for the lead (`startLeadStream`), opened with `?lead=true` and `after=0`. Each transcript is drawn only from its own stream, so a reconnect after a byre restart fills in both the history and anything missed. Selecting another task closes the old task stream. Events arriving together are drawn in one batch.
+11 -1
View File
@@ -117,13 +117,23 @@ export function subscribe(after, filter, onEvent, onState, onReady) {
check();
}
};
const interval = setInterval(check, 5_000);
let interval = 0;
const start = () => {
interval = setInterval(check, 5_000);
addEventListener("online", connect);
document.addEventListener("visibilitychange", onVisible);
connect();
};
if (document.readyState === "complete") {
start();
}
else {
addEventListener("load", start, { once: true });
}
return () => {
clearInterval(interval);
clearTimeout(retry);
removeEventListener("load", start);
removeEventListener("online", connect);
document.removeEventListener("visibilitychange", onVisible);
source?.close();
+1 -1
View File
File diff suppressed because one or more lines are too long
+11 -1
View File
@@ -186,13 +186,23 @@ export function subscribe(
}
};
const interval = setInterval(check, 5_000);
let interval = 0;
const start = () => {
interval = setInterval(check, 5_000);
addEventListener("online", connect);
document.addEventListener("visibilitychange", onVisible);
connect();
};
if (document.readyState === "complete") {
start();
} else {
addEventListener("load", start, { once: true });
}
return () => {
clearInterval(interval);
clearTimeout(retry);
removeEventListener("load", start);
removeEventListener("online", connect);
document.removeEventListener("visibilitychange", onVisible);
source?.close();