import { listFamily, type Session } from './api'; import { FAMILY_MS, familyDelay } from './agentTree'; import { busy } from './live.svelte'; import { sessions } from './sessions.svelte'; // The agents a session started (robot-docs/agents.md). A family is read whole // from its root, so a button in any window of it and the tree window show the // same agents, and they share one read of it rather than each making their own // (`familyDelay` says how often; a turn starting or ending reads it at once, `pokeFamily`). /** Gets the family, or null when the first read failed and there is nothing to show yet. */ type Listener = (family: Session[] | null) => void; interface Watch { listeners: Set; /** The newest family read, and its text: a read that changes nothing tells no one. */ last: Session[] | null; signature: string; poke: () => void; stop: () => void; } const watches = new Map(); function start(rootId: string): Watch { let timer: ReturnType; let reading = false; let again = false; const abort = new AbortController(); const watch: Watch = { listeners: new Set(), last: null, signature: '', poke: () => tick(), stop: () => {} }; const tick = async () => { clearTimeout(timer); // A tab that is hidden reads nothing; its return does (`shown`). if (document.hidden) return; if (reading) { again = true; return; } let delay = FAMILY_MS.retry; reading = true; const family = await listFamily(rootId, abort.signal).catch(() => null); reading = false; if (abort.signal.aborted) return; if (family) { // A session with no live worker (a closed agent) is stamped with the read's own time; that is no change. const signature = JSON.stringify(family, (key, value) => (key === 'observed_at' ? undefined : value)); if (signature !== watch.signature) { watch.signature = signature; watch.last = family; sessions.refreshAgents(family); for (const listener of watch.listeners) listener(family); } delay = familyDelay(family, rootId, busy, busy(sessions.byId.get(rootId))); } else if (!watch.last) { for (const listener of watch.listeners) listener(null); } if (again) { again = false; delay = 0; } timer = setTimeout(tick, delay); }; // A tab coming back reads the family now rather than after the long wait. const shown = () => !document.hidden && tick(); document.addEventListener('visibilitychange', shown); watch.stop = () => { clearTimeout(timer); abort.abort(); document.removeEventListener('visibilitychange', shown); }; tick(); return watch; } /** * Follows a family while something shows it; `on` gets the newest read at * once if there is one, and each read that changes something after. Every * window of a family shares the one read. */ export function watchFamily(rootId: string, on: Listener): () => void { let watch = watches.get(rootId); if (!watch) watches.set(rootId, (watch = start(rootId))); watch.listeners.add(on); if (watch.last) on(watch.last); const mine = watch; return () => { mine.listeners.delete(on); if (!mine.listeners.size) { mine.stop(); if (watches.get(rootId) === mine) watches.delete(rootId); } }; } /** Reads a family now, if anything is following it. */ export function pokeFamily(rootId: string) { watches.get(rootId)?.poke(); } /** What an agent is doing, in a word. */ export function stateOf(session: Session): 'working' | 'idle' | 'closed' { return session.closed ? 'closed' : busy(session) ? 'working' : 'idle'; }