[79d686f0] Add page-scoped refresh button to the navbar (#351)

* [870467e6] Frontend: page-scoped refresh provider, hook, and navbar button (#347)

* [55376b8a] Create page-scoped refresh provider and context (#327)

* [55376b8a] feat(panel): add page-scoped refresh context and provider

* [55376b8a] docs(frontend): add page-refresh-provider component documentation

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [a0c02d0f] Add public usePageRefresh hook (#332)

* [a0c02d0f] test(hooks): assert usePageRefresh is exported from hooks barrel

* [a0c02d0f] feat(hooks): add public usePageRefresh hook with provider and tests

* [a0c02d0f] fix(panel): move hook test wrappers to components and rename providers.tsx to unshadow barrel

* [a0c02d0f] docs(panel): document usePageRefresh hook and PageRefreshProvider API

---------

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>

* [5f28dd9b] Add navbar refresh button and remove inline dashboard refresh buttons (#336)

* [5f28dd9b] Align PageRefreshProvider with active hook API and remove inline dashboard refresh buttons

* [5f28dd9b] Remove unused scope-keyed PageRefreshProvider, context, and associated tests

* [5f28dd9b] Address QA revision: add header refresh tests, page-scoped label, remove dead provider code and .venv symlink, revert formatting-only changes

* [5f28dd9b] Remove remaining inline dashboard refresh buttons and committed .venv symlink

* [5f28dd9b] docs(frontend): update page-refresh provider docs and panel README for navbar refresh button

* [5f28dd9b] fix(panel): remove .venv symlink, ignore root .venv entries, and thin task-detail page data fetch into useTaskDetail hook

* [5f28dd9b] Extract GitBrowser data fetching into useGitBrowser hook and add tests; verify .venv cleanup and task-detail thin hook usage

* [5f28dd9b] fix(panel): remove root .venv symlink, restore .gitignore anchored rule, and revert lifecycle.json formatting noise

* Delete .venv

---------

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>

* [b8e1de1b] Fix navbar refresh button disabled state when registry is empty (#356) (#358)

* [b8e1de1b] fix(panel): derive navbar refresh disabled state from registry, not unused prop

PageRefreshProvider now computes `disabled` from whether any refresh
callback is currently registered (registry size > 0) instead of a
static, never-passed `disabled` prop that left the button permanently
enabled. header.tsx now destructures `disabled` from usePageRefresh()
and disables the button on `disabled || loading`. Updated the tests
that asserted the old always-enabled-by-default behavior and added a
new header test asserting the button is disabled with zero registered
callbacks.

* [b8e1de1b] docs(panel): document PageRefreshProvider disabled state derived from registry

Updated documentation to reflect the refactored PageRefreshProvider behavior: the `disabled` state is now derived from whether any refresh callbacks are currently registered (empty registry = disabled), rather than a static `disabled` prop. Clarified in both panel/README.md and the full component guide that the navbar refresh button disables when no callbacks are registered and when a refresh cycle is in progress. Updated API documentation to remove the now-removed `disabled` prop from PageRefreshProviderProps and updated code examples and test coverage descriptions to reflect the new callback-driven semantics.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* test(panel): mock usePageRefresh in tests predating the provider

Merge-skew: the page-refresh feature makes CommandCenter and the agent
detail page call usePageRefresh; three tests merged from master render
them without the new provider. Mock the hook module, matching the
files' stub-everything style.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-09 05:27:14 +02:00
committed by GitHub
co-authored by Frontend Developer 1 Frontend Documenter Frontend Developer 2 Renn F
parent ec5323917e
commit 08c02e2251
56 changed files with 1996 additions and 608 deletions
+6
View File
@@ -10,8 +10,14 @@ Documentation for the Frontend Cell team.
## Contents
- `/components/` - Component documentation
- [Page-scoped refresh provider](./components/page-refresh-provider.md) — `PageRefreshProvider` callback registry that lets the navbar refresh button re-fetch only the current page.
- `/hooks/` - Hook documentation
- `/qa/` - QA-related docs
## Available docs
- [`hooks.md`](./hooks.md) — `usePageRefresh` and `PageRefreshProvider` usage and API reference
## Contributing
Frontend team members should request documentation updates through the Cell PM. Only the Frontend Documenter (fe-doc) can write to this directory.
@@ -0,0 +1,192 @@
# 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.
+107
View File
@@ -0,0 +1,107 @@
# Frontend hooks
This page documents the public React hooks available under `panel/src/hooks`.
## `usePageRefresh`
A page-scoped refresh coordinator. Pages and panels register callbacks that refetch their data; UI chrome calls `refresh()` and reflects the combined `loading`/`disabled` state.
### When to use it
Use `usePageRefresh` when several components on the same page need to refresh together from a single trigger, such as a navbar refresh button. It keeps the refresh lifecycle scoped to the current page and avoids invalidating unrelated data.
### Setup
Wrap the page (or root layout) with `PageRefreshProvider`:
```tsx
import { PageRefreshProvider } from "@/components/providers";
export default function Layout({ children }: { children: React.ReactNode }) {
return <PageRefreshProvider>{children}</PageRefreshProvider>;
}
```
### Basic usage
```tsx
import { useEffect } from "react";
import { usePageRefresh } from "@/hooks";
import { useTasks } from "@/hooks";
export function TasksPanel() {
const { refetch } = useTasks();
const { register, unregister } = usePageRefresh();
useEffect(() => {
const refresh = () => refetch();
register(refresh);
return () => unregister(refresh);
}, [register, unregister, refetch]);
return <div>{/* task list */}</div>;
}
```
### Triggering a refresh from UI chrome
```tsx
import { usePageRefresh } from "@/hooks";
export function RefreshButton() {
const { refresh, loading, disabled } = usePageRefresh();
return (
<button onClick={refresh} disabled={disabled || loading}>
{loading ? "Refreshing…" : "Refresh"}
</button>
);
}
```
### Navbar refresh button
The canonical consumer is `panel/src/components/layout/header.tsx`. The refresh button is rendered between the connection-status badge and the theme toggle. Its accessible label and tooltip read **"Refresh only the current page"**, and it is disabled with a spinning icon while the registered refresh cycle is running.
Dashboard pages no longer include their own inline "Refresh" buttons. Instead, each page registers its refetch callbacks with `usePageRefresh` and lets the shared header button drive the refresh. See [`components/page-refresh-provider.md`](../components/page-refresh-provider.md) for the full list of wired pages and the registration pattern.
### API reference
#### `PageRefreshProvider`
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `children` | `React.ReactNode` | required | React tree that can consume the context. |
| `disabled` | `boolean` | `false` | When `true`, `refresh()` is ignored and `disabled` is exposed as `true`. |
#### `usePageRefresh`
Returns a `PageRefreshState` object:
| Property | Type | Description |
|----------|------|-------------|
| `disabled` | `boolean` | Whether refresh actions are currently disabled. |
| `loading` | `boolean` | Whether a refresh cycle is currently in progress. |
| `register` | `(callback: RefreshCallback) => void` | Add a callback to invoke on the next refresh. |
| `unregister` | `(callback: RefreshCallback) => void` | Remove a previously registered callback. |
| `refresh` | `() => Promise<void>` | Run every registered callback and update `loading`. |
`RefreshCallback` is `() => void | Promise<void>`. Synchronous and asynchronous callbacks are both supported.
### Behavior
- `usePageRefresh` throws if called outside a `PageRefreshProvider` so consumers fail fast instead of silently missing refreshes.
- Concurrent calls to `refresh()` are coalesced: a second call while one is running returns immediately and does not start another cycle.
- When `disabled` is `true`, `refresh()` is a no-op and callbacks are not invoked.
- `register` and `unregister` are stable across renders and can be used as `useEffect` dependencies.
### Exports
- `usePageRefresh` from `@/hooks`
- `PageRefreshProvider` from `@/components/providers`
- Types: `PageRefreshState`, `RefreshCallback`, `PageRefreshProviderProps`
### Migration notes
- `panel/src/components/providers.tsx` was renamed to `panel/src/components/app-providers.tsx` so that `@/components/providers` could be used as a barrel export for `PageRefreshProvider`. Update any direct import of the root providers component from `@/components/providers` to `@/components/app-providers`.
- The earlier scope-keyed provider files (`panel/src/components/page-refresh-provider.tsx` and `panel/src/store/page-refresh-context.ts`) were deleted. The current implementation lives in `panel/src/components/providers/page-refresh-provider.tsx` and is consumed through `usePageRefresh` from `@/hooks`.