Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
iris
d49a9df479 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.
2026-08-31 19:02:57 +02:00
iris
9c72a4ae3e 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.
2026-08-31 19:02:57 +02:00
2 changed files with 49 additions and 15 deletions

View file

@ -72,6 +72,17 @@ export function Root() {
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
const liveStreamRef = useRef<LiveStreamHandle>(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/

View file

@ -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';
@ -18,6 +23,15 @@ export function useAgentState(): UseAgentStateResult {
const [error, setError] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | 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;
@ -37,6 +51,7 @@ export function useAgentState(): UseAgentStateResult {
timerRef.current = setTimeout(poll, RETRY_MS);
}
}
pollRef.current = poll;
poll();
return () => {
@ -47,21 +62,29 @@ 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
// 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 };
}