From 9c72a4ae3e36065082134364f8b8bea62e176d75 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 31 Aug 2026 18:11:13 +0200 Subject: [PATCH 1/2] agent web UI: keep the turn-state badge live instead of freezing until reload The status badge's elapsed-time text ('thinking Xm Ys') was computed from Date.now() inside the render function, so it only ever advanced when the component actually re-rendered. Two gaps let it go stale: - useAgentState's poll loop is a chained setTimeout, which browsers throttle (or suspend outright) once the tab is backgrounded, so polling could stall for a long time with no way back to a live reading short of a full page reload. - even under healthy polling, the age text only advanced once every ~4s (the poll interval) instead of counting up smoothly. Fix: resync immediately on visibilitychange (so returning to a backgrounded tab doesn't leave a stale reading), and drive the age text off its own 1s interval independent of the poll cadence. --- frontend/packages/agent/src/Root.tsx | 13 ++++++++++- .../packages/agent/src/hooks/useAgentState.ts | 23 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx index f3b15400..36079502 100644 --- a/frontend/packages/agent/src/Root.tsx +++ b/frontend/packages/agent/src/Root.tsx @@ -72,6 +72,17 @@ 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 @@ -175,7 +186,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(Date.now() - state.turn_state_since * 1000); + const stateAge = fmtAge(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 306d8e49..d41bb94a 100644 --- a/frontend/packages/agent/src/hooks/useAgentState.ts +++ b/frontend/packages/agent/src/hooks/useAgentState.ts @@ -1,5 +1,10 @@ // useAgentState — polls `GET /api/state` every 4s and exposes the latest -// snapshot + loading/error status. +// 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'; @@ -63,5 +68,21 @@ export function useAgentState(): UseAgentStateResult { .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. `refresh` closes over stable refs/setters + // only, so a fresh instance each render is safe to use here. + useEffect(() => { + function onVisible() { + if (document.visibilityState === 'visible') refresh(); + } + document.addEventListener('visibilitychange', onVisible); + return () => document.removeEventListener('visibilitychange', onVisible); + }); + return { state, error, refresh }; } From d49a9df4792dad85011e3e17a40dba9c2d949ee8 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 31 Aug 2026 18:15:48 +0200 Subject: [PATCH 2/2] useAgentState: re-arm the poll loop from refresh() instead of killing it argus's review on the original fix: refresh() did a parallel one-off fetch instead of calling the mount effect's self-rescheduling poll(), so it never re-armed timerRef after firing. Wiring refresh() to run on every visibilitychange-to-visible meant the very first tab-switch back into focus would kill periodic polling for the rest of the session -- reproducing the exact staleness bug this branch set out to fix, just delayed by one tab switch instead of immediate. Fix: hoist poll into a ref set by the mount effect so refresh() invokes the same self-rescheduling function rather than a parallel fetch that drops the loop. Also gave the visibilitychange effect an empty dependency array per the review's second note -- it only closes over stable refs/setters, and now that Root's 1s ticker re-renders the calling component every second, a deps-less effect would tear down and reattach the listener that often for no reason. --- .../packages/agent/src/hooks/useAgentState.ts | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/frontend/packages/agent/src/hooks/useAgentState.ts b/frontend/packages/agent/src/hooks/useAgentState.ts index d41bb94a..4893ecb1 100644 --- a/frontend/packages/agent/src/hooks/useAgentState.ts +++ b/frontend/packages/agent/src/hooks/useAgentState.ts @@ -23,6 +23,15 @@ 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; @@ -42,6 +51,7 @@ export function useAgentState(): UseAgentStateResult { timerRef.current = setTimeout(poll, RETRY_MS); } } + pollRef.current = poll; poll(); return () => { @@ -52,20 +62,8 @@ 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; - 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))); + pollRef.current(); } // A backgrounded/hidden tab has its `setTimeout` chain throttled by the @@ -74,15 +72,19 @@ export function useAgentState(): UseAgentStateResult { // 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. `refresh` closes over stable refs/setters - // only, so a fresh instance each render is safe to use here. + // 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 }; }