How Pi Web Reconciles Running State Polling on Visibility Change and VisibilityState
Pi Web synchronizes its UI with the server-side AgentSession through a dual mechanism: a 15-second polling loop and a visibilitychange event listener that triggers immediate reconciliation when the document becomes visible.
The useAgentSession hook in the agegr/pi-web repository implements robust state reconciliation to prevent the UI from getting stuck in a streaming state when browser tabs are backgrounded or network connectivity fluctuates. This article examines how visibility-based reconciliation works alongside periodic polling to maintain accurate agent state.
The Polling and Visibility Architecture
The reconciliation system operates through three coordinated triggers defined in hooks/useAgentSession.ts. Each trigger ensures the client catches server-side state changes that might be missed during periods of inactivity.
Periodic Polling Loop
When an agent is actively running (agentRunning === true), the hook establishes a recurring timer:
const interval = setInterval(reconcile, AGENT_STATE_RECONCILE_MS);
The AGENT_STATE_RECONCILE_MS constant is set to 15 seconds, providing a baseline heartbeat that checks server state regardless of user interaction. This interval calls reconcileAgentState(sid) with the current session ID.
visibilitychange Event Listener
The same effect registers a handler for browser visibility transitions:
const onVisible = () => {
if (document.visibilityState === "visible") reconcile();
};
document.addEventListener("visibilitychange", onVisible);
When document.visibilityState === "visible" evaluates to true—typically when the user returns to a previously backgrounded tab—the reconciliation function fires immediately. This eliminates latency that would otherwise occur if the user had to wait for the next 15-second polling cycle.
online Event Listener
A third trigger responds to network recovery:
window.addEventListener("online", reconcile);
This ensures state synchronization after connectivity interruptions without requiring page refreshes.
Reconciliation Logic and State Transitions
The reconcileAgentState function at lines 1058-1089 of hooks/useAgentSession.ts performs the actual server-client synchronization. It fetches from /api/agent/<sid> and interprets the returned JSON structure.
Active State Detection
const { isStreaming, isPromptRunning, isCompacting } = data.state ?? {};
if (isStreaming || isPromptRunning || isCompacting) {
sdkAgentActiveRef.current = Boolean(isStreaming);
rpcPromptPendingRef.current = Boolean(isPromptRunning);
return;
}
When any of these three flags are active, the function updates React refs to maintain the running UI state and exits without further action.
Idle State Handling
When the server reports no active operations, the reconciliation completes the session:
await finishPromptWithoutStream(sid, runId);
This call loads final session data and cleans up the UI, transitioning from the running state to completion.
Effect Cleanup and Resource Management
The useEffect in hooks/useAgentSession.ts (lines 1012-1015) prevents memory leaks and duplicate operations:
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("online", reconcile);
};
Cleanup occurs when:
- The component unmounts
agentRunningtransitions tofalse- Dependencies change and the effect re-runs
Why visibilityState Matters for Agent Sessions
Browser throttling of background tabs creates specific risks for streaming UIs. Timers may fire less frequently, WebSocket connections may stall, and SSE events can be dropped. The visibilityState check provides three critical protections:
- Immediate catch-up — Users returning to a tab see current state without polling delay
- Throttling bypass — The
visibilitychangeevent fires reliably even whensetIntervalis degraded - Server load balancing — Polling pauses don't create spurious requests; reconciliation happens precisely when needed
Complete Implementation Reference
// From hooks/useAgentSession.ts lines 998-1015
useEffect(() => {
if (!agentRunning) return;
const reconcile = () => {
const sid = sessionIdRef.current;
if (sid) void reconcileAgentState(sid);
};
const onVisible = () => {
if (document.visibilityState === "visible") reconcile();
};
const interval = setInterval(reconcile, AGENT_STATE_RECONCILE_MS);
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("online", reconcile);
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("online", reconcile);
};
}, [agentRunning, reconcileAgentState]);
Summary
- Polling interval: 15-second
setIntervalinhooks/useAgentSession.tsprovides baseline synchronization - Visibility trigger:
visibilitychangelistener withdocument.visibilityState === "visible"check enables immediate reconciliation when users return to tabs - Network trigger:
onlineevent handles connectivity recovery scenarios - Server endpoint:
/api/agent/[id]returnsisStreaming,isPromptRunning, andisCompactingflags for state determination - Cleanup strategy: Effect teardown removes all timers and listeners to prevent memory leaks
Frequently Asked Questions
How does Pi Web prevent the UI from getting stuck when a tab is backgrounded?
The visibilitychange event listener in hooks/useAgentSession.ts triggers reconcileAgentState immediately when document.visibilityState becomes "visible". This catches any server-side state changes that occurred while the tab was hidden, bypassing browser timer throttling that might delay the standard 15-second polling interval.
What happens if the network reconnects after being offline?
The online event listener registered on window fires reconcileAgentState when connectivity resumes. This ensures the client fetches current agent state without requiring a page refresh or waiting for the next polling cycle.
Why check document.visibilityState instead of just listening for the event?
The visibilitychange event fires on both hide and show transitions. Explicitly checking document.visibilityState === "visible" ensures reconciliation only occurs when the document becomes visible, avoiding unnecessary server requests when the user navigates away from the tab.
Where does the reconciliation logic decide whether to keep or end the running state?
Lines 1058-1089 of hooks/useAgentSession.ts contain the decision logic. If the server response contains isStreaming, isPromptRunning, or isCompacting flags, the function updates refs and maintains the running UI. Absent these flags, it calls finishPromptWithoutStream to complete the session and clean up.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →