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.
This commit is contained in:
iris 2026-08-31 18:11:13 +02:00 committed by mara
commit 9c72a4ae3e
2 changed files with 34 additions and 2 deletions

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';
@ -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 };
}