# How Pi Web Reconciles Running State Polling on Visibility Change and VisibilityState

> Discover how Pi Web reconciles running state polling with visibilitychange and visibilityState using a dual mechanism of polling and event listeners for seamless UI updates.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: internals
- Published: 2026-08-16

---

**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`](https://github.com/agegr/pi-web/blob/main/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:

```typescript
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:

```typescript
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:

```typescript
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`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) performs the actual server-client synchronization. It fetches from `/api/agent/<sid>` and interprets the returned JSON structure.

### Active State Detection

```typescript
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:

```typescript
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`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) (lines 1012-1015) prevents memory leaks and duplicate operations:

```typescript
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

```typescript
// 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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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.