diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx index 36079502..f3b15400 100644 --- a/frontend/packages/agent/src/Root.tsx +++ b/frontend/packages/agent/src/Root.tsx @@ -72,17 +72,6 @@ export function Root() { const [openPanel, setOpenPanel] = useState(null); const liveStreamRef = useRef(null); - // Ticks the status badge's "thinking Xm Ys" age once a second, - // independent of the ~4s `/api/state` poll cadence — without its own - // clock the age text only advances when a poll happens to land, so it - // visibly stair-steps instead of counting up, and freezes outright for - // however long a poll is delayed (e.g. a backgrounded tab). - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - const id = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(id); - }, []); - // Browser tab title — ports app.js's setHeader logic verbatim: prefer // the human display name + hive_name ("iris // pr1ma") so tabs read // naturally; fall back to the qualified domain label for multi-hive @@ -186,7 +175,7 @@ export function Root() { // idle/thinking reading from before the harness went offline). const effectiveTurnState = state.status === 'online' ? state.turn_state : 'offline'; const turnDef = STATE_LABELS[effectiveTurnState] ?? { glyph: '○', text: 'offline', tone: 'negative' as BadgeTone }; - const stateAge = fmtAge(now - state.turn_state_since * 1000); + const stateAge = fmtAge(Date.now() - state.turn_state_since * 1000); // Consolidated status badge (mara: "why do we have separate alive // badge? ... one badge that shows thinking / idle / paused"). // Not online → the alive-table's own reading (rate-limited/needs-login/ diff --git a/frontend/packages/agent/src/hooks/useAgentState.ts b/frontend/packages/agent/src/hooks/useAgentState.ts index 4893ecb1..306d8e49 100644 --- a/frontend/packages/agent/src/hooks/useAgentState.ts +++ b/frontend/packages/agent/src/hooks/useAgentState.ts @@ -1,10 +1,5 @@ // 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. +// snapshot + loading/error status. import { useEffect, useRef, useState } from 'preact/hooks'; import type { AgentState } from '../types.js'; @@ -23,15 +18,6 @@ export function useAgentState(): UseAgentStateResult { 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; @@ -51,7 +37,6 @@ export function useAgentState(): UseAgentStateResult { timerRef.current = setTimeout(poll, RETRY_MS); } } - pollRef.current = poll; poll(); return () => { @@ -62,29 +47,21 @@ export function useAgentState(): UseAgentStateResult { function refresh() { if (timerRef.current) clearTimeout(timerRef.current); + // Re-trigger the same poll loop immediately; the effect's closure + // owns `poll`, so the simplest safe re-trigger from outside it is a + // fresh fetch here rather than reaching back into the effect. stoppedRef.current = false; - pollRef.current(); + fetch('api/state') + .then((resp) => { + if (!resp.ok) throw new Error(`http ${resp.status}`); + return resp.json(); + }) + .then((s: AgentState) => { + setState(s); + setError(null); + }) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); } - // 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 }; }