# Page-scoped refresh provider A React Context + provider that lets global UI chrome (the navbar refresh button) trigger a refresh that is scoped to the page the user is currently viewing. ## Purpose Several dashboard pages fetch their own data through TanStack Query. A global refresh button in the header needs to re-fetch data for the *current* page without invalidating every other page's cache. `PageRefreshProvider` maintains a simple callback registry: pages register their own refetch callbacks when mounted, and the navbar button invokes every registered callback when clicked. This keeps refresh semantics page-local. Only the components that are currently mounted and have registered callbacks participate in a refresh cycle. ## Files | File | Role | |------|------| | `panel/src/components/providers/page-refresh-provider.tsx` | Provider component, context value, and `RefreshCallback` / `PageRefreshState` types. | | `panel/src/components/providers/index.ts` | Barrel export (`PageRefreshProvider`, `PageRefreshContext`, types). | | `panel/src/hooks/use-page-refresh.ts` | Public `usePageRefresh` hook that consumes the context. | | `panel/src/components/app-providers.tsx` | Root provider stack; wraps the app in `PageRefreshProvider`. | | `panel/src/components/layout/header.tsx` | Navbar refresh button that calls `refresh()` and reflects `loading`. | ## API ### `PageRefreshState` ```ts interface PageRefreshState { disabled: boolean; loading: boolean; register: (callback: RefreshCallback) => void; unregister: (callback: RefreshCallback) => void; refresh: () => Promise; } ``` - `disabled` — whether there are no refresh callbacks currently registered (the registry is empty). When true, clicking refresh is a no-op. - `loading` — whether a refresh cycle is currently running. - `register(callback)` — add a callback to invoke on the next refresh. - `unregister(callback)` — remove a previously registered callback. - `refresh()` — invoke every registered callback concurrently and update `loading` until they settle. Does nothing if the registry is empty. ### `RefreshCallback` ```ts type RefreshCallback = () => void | Promise; ``` May be sync or async; `refresh` always returns a `Promise` and awaits async callbacks with `Promise.all`. ### `PageRefreshProviderProps` ```ts interface PageRefreshProviderProps { children: React.ReactNode; } ``` - `children` — React tree that can consume the context. The provider does not expose a `disabled` prop; instead, `disabled` is derived from whether any refresh callbacks are currently registered (the registry size). ## How to consume Pages and panels that want to expose a refresh action should: 1. Import `usePageRefresh` from `@/hooks`. 2. In a `useEffect`, register a callback that refetches the page's data. 3. Unregister the same callback on unmount. ```tsx "use client"; import { useEffect } from "react"; import { usePageRefresh } from "@/hooks"; import { useProducts } from "@/hooks/use-products"; export default function ProductsPage() { const { data: products, error, refetch } = useProducts(); const { register, unregister, refresh } = usePageRefresh(); useEffect(() => { const cb = () => { void refetch(); }; register(cb); return () => unregister(cb); }, [register, unregister, refetch]); if (error) { return void refresh()} />; } return ; } ``` ## How the navbar button triggers refresh `panel/src/components/layout/header.tsx` consumes the same context: ```tsx const { refresh, loading, disabled } = usePageRefresh(); ``` The button sits between the connection-status badge and the theme toggle. It is disabled when: - No page has registered a refresh callback (the registry is empty) - A refresh cycle is currently running When disabled, the button is grayed out and clicking has no effect. While loading, the icon shows a spinner. Concurrent clicks are coalesced: a second click while a cycle is in progress does nothing. ## Pages wired to the navbar refresh The following routes and dashboard components register refetch callbacks with `usePageRefresh`. New pages should follow the same pattern. | Route / component | Registered refetch(es) | |---|---| | `app/(dashboard)/a2a/page.tsx` | `refetchConversations`, `refetchPairs`, `refetchMessages` | | `app/(dashboard)/agents/page.tsx` | `refetch` (orchestrator status) | | `app/(dashboard)/agents/[agentId]/page.tsx` | `refetch` (agent status) | | `app/(dashboard)/journals/page.tsx` | `refetch` (agent list) | | `app/(dashboard)/journals/[entryId]/page.tsx` | `refetch` (journal entry) | | `app/(dashboard)/metrics/page.tsx` | `refetchTasks`, `refetchStatus` | | `app/(dashboard)/notifications/page.tsx` | `refetch` (notifications list) | | `app/(dashboard)/products/page.tsx` | `refetch` (products) | | `app/(dashboard)/projects/page.tsx` | `refetch` (projects) | | `app/(dashboard)/tasks/page.tsx` | `refetch` (tasks) | | `app/(dashboard)/tasks/[taskId]/page.tsx` | `refetch` (task detail) | | `app/(dashboard)/work-sessions/page.tsx` | `refetch` (work sessions) | | `components/auditor/auditor-dashboard.tsx` | `refetch` (auditor dashboard) | | `components/business/pitches-tab.tsx` | `refetch` (pitches) | | `components/dashboard/command-center.tsx` | `refetch` (CEO overview) | | `components/dashboard/release-proposal-card.tsx` | `refetch` (release proposal) | | `components/dashboard/roadmap-review-queue.tsx` | `refetch` (roadmap cycles) | | `components/dashboard/x-post-queue.tsx` | `refetch` (X post queue) | | `components/git/git-browser.tsx` | project/status/log/branches refetches | | `components/kanban/core/kanban-board.tsx` | `refetch` (kanban tasks) | | `components/knowledge-base/knowledge-base-browser.tsx` | stats/health refetches | ## Design decisions - **Callback-set registry, not query invalidation**: the provider stores `Set` and lets each page decide how to refresh. This avoids spraying React Query cache invalidations across unrelated pages. - **No scope keys**: the previous implementation keyed callbacks by a scope string and tracked an "active scope". That was removed because React mount/unmount lifecycle already scopes callbacks to the visible page; extra scope bookkeeping added complexity without benefit. - **React Context over Zustand**: the state is transient and tied to the React tree, so Context is the lighter fit. - **Coalesced concurrent refreshes**: `refresh()` ignores subsequent calls while a cycle is already running, preventing double-refetch and keeping the button disabled honestly. - **Provider types live next to the provider**: unlike the first implementation, the context value types and `RefreshCallback` type now live in `components/providers/page-refresh-provider.tsx` and are re-exported by `components/providers/index.ts`. This matches the current panel boundary that keeps provider primitives together. ## Testing Run the provider-related tests with the panel test suite: ```bash cd panel pnpm test page-refresh ``` Covered behaviors: - `usePageRefresh` throws when called outside a `PageRefreshProvider`. - The hook returns `disabled: true` when nothing is registered, and `disabled: false` once a callback is registered. - Registering callbacks makes `refresh()` invoke them and sets `disabled: false`. - Unregistering callbacks prevents the callback from being called and returns `disabled: true` when the registry is empty. - `refresh()` returns a promise and awaits async callbacks. - `refresh()` is a no-op when the registry is empty (disabled). - The navbar button renders between the connection-status badge and the theme toggle. - The navbar button exposes an accessible page-scoped label. - The navbar button is disabled when no callbacks are registered and shows a spinner while a refresh callback is running. - Concurrent clicks do not start a second refresh cycle. ## Related work - Completed prerequisite: **Add public `usePageRefresh` hook** — consumes this provider. - This task: **Add navbar refresh button and remove inline dashboard refresh buttons** — wires the header button and removes per-page inline buttons. ## Migration / rollout No consumer migration is needed for end users. For developers adding a new dashboard page: 1. Wrap tests for the page in `PageRefreshProvider` from `@/components/providers` if they render page-level components that call `usePageRefresh`. 2. Register the page's refetch callbacks and unregister them on unmount. 3. Do not add a new inline "Refresh" button; use the shared navbar button instead. Operation-specific refresh controls (for example, a "Reindex" button inside a knowledge-base card or a "Retry" on an `OfflineState`) are intentionally preserved when their action is local to a sub-component, not a whole-page refresh.