Files

193 lines
8.9 KiB
Markdown
Raw Permalink Normal View History

# 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<void>;
}
```
- `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<void>;
```
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 <OfflineState onRetry={() => void refresh()} />;
}
return <ProductTable products={products} />;
}
```
## How the navbar button triggers refresh
`panel/src/components/layout/header.tsx` consumes the same context:
```tsx
const { refresh, loading, disabled } = usePageRefresh();
<Button
variant="ghost"
size="icon"
onClick={() => void refresh()}
disabled={disabled || loading}
aria-label="Refresh only the current page"
title="Refresh only the current page"
>
<RefreshCw className={cn("h-5 w-5", loading && "animate-spin")} />
</Button>
```
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<RefreshCallback>` 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.