// useAgentState — polls `GET /api/state` every 4s and exposes the latest // snapshot + loading/error status. Also resyncs immediately whenever the // tab regains visibility: a backgrounded tab's `setTimeout` chain gets // throttled/frozen by the browser, so the last snapshot — and anything // the UI derives from it, like the turn-state badge's elapsed-time text — // goes stale until something proactively refetches; without this the // only way back to a live reading was a full page reload. import { useEffect, useRef, useState } from "preact/hooks"; import type { AgentState } from "../types.js"; const POLL_MS = 4000; const RETRY_MS = 5000; export interface UseAgentStateResult { state: AgentState | null; error: string | null; /** Force an immediate refresh (e.g. right after a model/effort POST). */ refresh: () => void; } export function useAgentState(): UseAgentStateResult { const [state, setState] = useState(null); const [error, setError] = useState(null); const timerRef = useRef | null>(null); const stoppedRef = useRef(false); // Holds the mount effect's self-rescheduling `poll` so `refresh()` can // invoke the *same* function instead of a parallel one-off fetch — a // fresh call still runs through `poll`'s own `setTimeout(poll, …)` tail, // so the loop stays alive afterwards instead of dying silently the // first time something calls `refresh()` (see the review discussion on // this file's PR: an earlier version of `refresh()` did its own // one-off fetch with no reschedule, which permanently killed periodic // polling the first time a caller invoked it). const pollRef = useRef<() => void>(() => {}); useEffect(() => { stoppedRef.current = false; async function poll() { try { const resp = await fetch("api/state"); if (!resp.ok) throw new Error(`http ${resp.status}`); const s = (await resp.json()) as AgentState; if (stoppedRef.current) return; setState(s); setError(null); timerRef.current = setTimeout(poll, POLL_MS); } catch (err) { if (stoppedRef.current) return; setError(err instanceof Error ? err.message : String(err)); timerRef.current = setTimeout(poll, RETRY_MS); } } pollRef.current = poll; poll(); return () => { stoppedRef.current = true; if (timerRef.current) clearTimeout(timerRef.current); }; }, []); function refresh() { if (timerRef.current) clearTimeout(timerRef.current); stoppedRef.current = false; pollRef.current(); } // A backgrounded/hidden tab has its `setTimeout` chain throttled by the // browser (Chrome et al throttle nested timers heavily once hidden, and // may suspend the page outright) — the `poll()` loop above effectively // stalls, and everything derived from `state` (not just the fetch // itself) goes stale until it happens to fire again. Force a resync the // moment the tab becomes visible so switching back doesn't leave a // reading that's minutes old. Empty deps: `refresh` only closes over // refs and setState setters, both stable across renders, so the first // render's closure stays correct for the component's whole lifetime — // and, unlike re-subscribing on every render, doesn't re-attach this // listener every second now that `Root`'s own 1s ticker re-renders the // component that calls this hook. useEffect(() => { function onVisible() { if (document.visibilityState === "visible") refresh(); } document.addEventListener("visibilitychange", onVisible); return () => document.removeEventListener("visibilitychange", onVisible); }, []); return { state, error, refresh }; }