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
  • agentRunning transitions to false
  • 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:

  1. Immediate catch-up — Users returning to a tab see current state without polling delay
  2. Throttling bypass — The visibilitychange event fires reliably even when setInterval is degraded
  3. 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 setInterval in hooks/useAgentSession.ts provides baseline synchronization
  • Visibility trigger: visibilitychange listener with document.visibilityState === "visible" check enables immediate reconciliation when users return to tabs
  • Network trigger: online event handles connectivity recovery scenarios
  • Server endpoint: /api/agent/[id] returns isStreaming, isPromptRunning, and isCompacting flags 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →