mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
193 lines
8.9 KiB
Markdown
193 lines
8.9 KiB
Markdown
# 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.
|