[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`.
+21
View File
@@ -51,6 +51,27 @@ That gives you Next dev-server on `localhost:3000`, but you still need the orche
- `src/lib/api/` — typed API client (thin wrappers over `fetch`)
- `src/lib/` — constants, utilities, WebSocket hooks
- `src/types/` — shared TypeScript types mirroring backend schemas
- `src/hooks/` — reusable React hooks (see [Frontend hooks](../docs/frontend/hooks.md))
## Hooks
The panel exposes public hooks under `@/hooks`. See [Frontend hooks](../docs/frontend/hooks.md) for full API reference and examples.
### `usePageRefresh`
Page-scoped refresh coordinator. Pages register data-refetch callbacks; the navbar refresh button in `src/components/layout/header.tsx` calls `refresh()` and reflects the combined `loading`/`disabled` state. The button is disabled when no callbacks are registered (the registry is empty) and while a refresh is in progress.
```tsx
import { usePageRefresh } from "@/hooks";
const { register, unregister, refresh, loading, disabled } = usePageRefresh();
```
- `disabled` is `true` when no callbacks are registered (there is nothing to refresh)
- `disabled` becomes `false` once a callback is registered
- `disabled` returns to `true` when all callbacks are unregistered
Wrap your page or layout in `PageRefreshProvider` from `@/components/providers` before consuming the hook. Dashboard pages should register their refetch callbacks and avoid adding inline "Refresh" buttons; see [`docs/frontend/components/page-refresh-provider.md`](../docs/frontend/components/page-refresh-provider.md) for the full wiring list and examples.
## Dependency Management
+11 -11
View File
@@ -44,7 +44,7 @@
"pr_reviewer"
],
"composes": [],
"description": "Claim an assembled-PR review task (awaiting_pr_review) WITHOUT transitioning it \u2014 mirrors QA's claim_review. The assembled diff and the parent task's acceptance criteria are returned inline.",
"description": "Claim an assembled-PR review task (awaiting_pr_review) WITHOUT transitioning it mirrors QA's claim_review. The assembled diff and the parent task's acceptance criteria are returned inline.",
"name": "claim_gate_review",
"pre_side_effects": [],
"side_effects": []
@@ -80,7 +80,7 @@
"composes": [
"complete"
],
"description": "Cell PM merges the PR (leaf into the cell branch, or the gated cell\u2192root PR into the root branch) + transitions to completed; Main PM escalates the root to the CEO (who merges root\u2192master). The merge runs BEFORE the complete transition: TaskService.complete asserts the PR is already merged, so the choreographer verb body (cell_pm_complete / main_pm_complete) owns the merge-first ordering \u2014 no trailing pr_merge side_effect is declared here.",
"description": "Cell PM merges the PR (leaf into the cell branch, or the gated cellroot PR into the root branch) + transitions to completed; Main PM escalates the root to the CEO (who merges rootmaster). The merge runs BEFORE the complete transition: TaskService.complete asserts the PR is already merged, so the choreographer verb body (cell_pm_complete / main_pm_complete) owns the merge-first ordering no trailing pr_merge side_effect is declared here.",
"name": "complete",
"pre_side_effects": [],
"side_effects": []
@@ -104,7 +104,7 @@
"composes": [
"create_subtask"
],
"description": "Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/research, UX devs also design). documentation is NOT delegatable \u2014 the lifecycle auto-creates the doc phase after the code subtask passes QA.",
"description": "Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/research, UX devs also design). documentation is NOT delegatable the lifecycle auto-creates the doc phase after the code subtask passes QA.",
"name": "delegate",
"pre_side_effects": [],
"side_effects": []
@@ -141,7 +141,7 @@
"composes": [
"qa_fail"
],
"description": "Fail QA with concrete issues. Transitions to needs_revision.",
"description": "Fail QA with concrete issues. Transitions needs_revision.",
"name": "fail_review",
"pre_side_effects": [],
"side_effects": []
@@ -203,7 +203,7 @@
"secretary"
],
"composes": [],
"description": "Signal you have no active work. PMs auto-pause owned in_progress tasks.",
"description": "Signal you have no work. PMs auto-pause owned in_progress tasks.",
"name": "i_am_idle",
"pre_side_effects": [],
"side_effects": []
@@ -315,7 +315,7 @@
"cell_pm"
],
"composes": [],
"description": "Hand a claimed/in_progress task to another developer in your own cell. The branch is keyed to the task (not the agent), so it is preserved \u2014 the new developer continues the work-in-progress. No status change.",
"description": "Hand a claimed/in_progress task to another developer in your own cell. The branch is keyed to the task (not the agent), so it is preserved the new developer continues the work-in-progress. No status change.",
"name": "reassign",
"pre_side_effects": [],
"side_effects": []
@@ -328,7 +328,7 @@
"composes": [
"request_changes"
],
"description": "Reject the merge review with concrete issues. Transitions awaiting_pm_review -> needs_revision, routed back like a QA fail (original developer for a leaf, revision PM for an assembled task). Use this for an AC/scope violation caught at merge review \u2014 never i_am_blocked/escalate, which have no revision routing.",
"description": "Reject the merge review with concrete issues. Transitions awaiting_pm_review -> needs_revision, routed back like a QA fail (original developer for a leaf, revision PM for an assembled task). Use this for an AC/scope violation caught at merge review never i_am_blocked/escalate, which have no revision routing.",
"name": "request_changes",
"pre_side_effects": [],
"side_effects": []
@@ -356,7 +356,7 @@
"composes": [
"submit_for_review"
],
"description": "Main PM opens the root\u2192master PR and moves the root task to awaiting_pr_review for the main reviewer (the root analogue of the cell PM's submit_up). After pr_pass, call complete to escalate to the CEO. For branch-bearing roots (a Main-PM root-subtask assembles the cells' merged work); branchless coordination roots skip the gate and complete directly. The gate is branch-keyed, not task_type-keyed \u2014 a Main-PM root is planning-typed, never code.",
"description": "Main PM opens the rootmaster PR and moves the root task to awaiting_pr_review for the main reviewer (the root analogue of the cell PM's submit_up). After pr_pass, call complete to escalate to the CEO. For branch-bearing roots (a Main-PM root-subtask assembles the cells' merged work); branchless coordination roots skip the gate and complete directly. The gate is branch-keyed, not task_type-keyed — a root is planning-typed, never code.",
"name": "submit_root",
"pre_side_effects": [
"create_root_pr"
@@ -370,7 +370,7 @@
"composes": [
"submit_for_review"
],
"description": "Cell PM opens the cell\u2192root PR and moves the cell task into the PR-review gate (awaiting_pr_review). The cell reviewer reviews the assembled diff; after pr_pass the same Cell PM completes it.",
"description": "Cell PM opens the cellroot PR and moves the cell task into the PR-review gate (awaiting_pr_review). The cell reviewer reviews the assembled diff; after pr_pass the same Cell PM completes it.",
"name": "submit_up",
"pre_side_effects": [
"create_pr"
@@ -382,7 +382,7 @@
"developer"
],
"composes": [],
"description": "Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base \u2014 e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned \u2014 resolve by hand, commit, then sync_branch again. Pass stash=True to auto-stash uncommitted changes instead of refusing DIRTY_WORKSPACE; they are restored after the rebase.",
"description": "Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned resolve by hand, commit, then sync_branch again.",
"name": "sync_branch",
"pre_side_effects": [],
"side_effects": []
@@ -434,7 +434,7 @@
"qa"
],
"composes": [],
"description": "Voluntarily release a claim back to pending. The work-in-progress branch is preserved. A PR reviewer who claimed an external review (in_progress) or a gate review (awaiting_pr_review) and cannot finish releases the claim here rather than wedging the lane until the stale-claim reaper.",
"description": "Voluntarily release a claim back to pending. The branch is keyed to the task (not the agent), so it is preserved — the new developer continues the work-in-progress. No status change.",
"name": "unclaim",
"pre_side_effects": [],
"side_effects": []
+8 -22
View File
@@ -1,11 +1,4 @@
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
} from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
describe("proxy", () => {
@@ -23,9 +16,7 @@ describe("proxy", () => {
}) as unknown as typeof fetch;
const { proxy } = await import("../proxy");
const res = await proxy(
new NextRequest("http://localhost:3000/overview"),
);
const res = await proxy(new NextRequest("http://localhost:3000/overview"));
expect(res.status).toBe(200);
});
@@ -36,9 +27,7 @@ describe("proxy", () => {
}) as unknown as typeof fetch;
const { proxy } = await import("../proxy");
const res = await proxy(
new NextRequest("http://localhost:3000/overview"),
);
const res = await proxy(new NextRequest("http://localhost:3000/overview"));
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("/login");
});
@@ -61,20 +50,17 @@ describe("proxy", () => {
global.fetch = vi.fn().mockRejectedValue(new Error("network down"));
const { proxy } = await import("../proxy");
const res = await proxy(
new NextRequest("http://localhost:3000/overview"),
);
const res = await proxy(new NextRequest("http://localhost:3000/overview"));
expect(res.status).toBe(200);
});
it("fails open when the status probe returns a non-ok response", async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false }) as unknown as
typeof fetch;
global.fetch = vi
.fn()
.mockResolvedValue({ ok: false }) as unknown as typeof fetch;
const { proxy } = await import("../proxy");
const res = await proxy(
new NextRequest("http://localhost:3000/overview"),
);
const res = await proxy(new NextRequest("http://localhost:3000/overview"));
expect(res.status).toBe(200);
});
});
@@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import type { ReactNode } from "react";
import { PageRefreshProvider } from "@/components/providers";
import type {
AdminConversationSummary,
AdminPairSummary,
@@ -63,6 +65,10 @@ vi.mock("sonner", () => ({
import A2APage from "../page";
function withPageRefresh(ui: ReactNode) {
return <PageRefreshProvider>{ui}</PageRefreshProvider>;
}
function buildConversation(
overrides: Partial<AdminConversationSummary> = {},
): AdminConversationSummary {
@@ -142,7 +148,7 @@ describe("A2APage", () => {
});
it("shows the transcript pane and composer for a task-linked conversation", () => {
render(<A2APage />);
render(withPageRefresh(<A2APage />));
expect(screen.getByText("transcript body text")).toBeInTheDocument();
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
expect(
@@ -162,7 +168,7 @@ describe("A2APage", () => {
error: null,
refetch: vi.fn(),
});
render(<A2APage />);
render(withPageRefresh(<A2APage />));
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
});
@@ -175,7 +181,7 @@ describe("A2APage", () => {
error: null,
refetch: vi.fn(),
});
render(<A2APage />);
render(withPageRefresh(<A2APage />));
expect(screen.queryByPlaceholderText(/chime in/i)).not.toBeInTheDocument();
expect(
screen.getByText(/no linked task, so a reply can't be sent/i),
@@ -196,7 +202,7 @@ describe("A2APage", () => {
a2aMessages: [],
isConnected: true,
});
render(<A2APage />);
render(withPageRefresh(<A2APage />));
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.conversations,
});
@@ -218,7 +224,7 @@ describe("A2APage", () => {
a2aMessages: [],
isConnected: false,
});
render(<A2APage />);
render(withPageRefresh(<A2APage />));
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.conversations,
});
@@ -237,7 +243,7 @@ describe("A2APage", () => {
isLoading: false,
refetch: vi.fn(),
});
render(<A2APage />);
render(withPageRefresh(<A2APage />));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
expect(screen.getByText(/Backend Cell/)).toBeInTheDocument();
});
@@ -248,7 +254,7 @@ describe("A2APage", () => {
isLoading: false,
refetch: vi.fn(),
});
render(<A2APage />);
render(withPageRefresh(<A2APage />));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
fireEvent.click(screen.getByTitle("Classic conversation list"));
@@ -268,7 +274,7 @@ describe("A2APage", () => {
a2aMessages: [],
isConnected: false,
});
const { rerender } = render(<A2APage />);
const { rerender } = render(withPageRefresh(<A2APage />));
// No invalidation while offline.
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
@@ -281,7 +287,7 @@ describe("A2APage", () => {
isConnected: true,
});
act(() => {
rerender(<A2APage />);
rerender(withPageRefresh(<A2APage />));
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
@@ -297,7 +303,7 @@ describe("A2APage", () => {
a2aMessages: [],
isConnected: true,
});
render(<A2APage />);
render(withPageRefresh(<A2APage />));
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
+28 -12
View File
@@ -17,6 +17,7 @@ import {
useA2AMessages,
} from "@/hooks/use-a2a-live";
import { useA2ALiveStream } from "@/hooks/use-websocket";
import { usePageRefresh } from "@/hooks";
import { A2AConversationList } from "@/components/a2a/a2a-conversation-list";
import { A2ASwitchboard } from "@/components/a2a/a2a-switchboard";
import { A2ATranscript } from "@/components/a2a/a2a-transcript";
@@ -37,7 +38,6 @@ import {
List as ListIcon,
MessagesSquare,
Radio,
RefreshCw,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
@@ -97,6 +97,32 @@ function A2APageContent() {
refetch: refetchMessages,
} = useA2AMessages(selectedId);
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const callbacks = [
() => {
void refetchConversations();
},
() => {
void refetchPairs();
},
() => {
void refetchMessages();
},
];
callbacks.forEach((cb) => register(cb));
return () => {
callbacks.forEach((cb) => unregister(cb));
};
}, [
register,
unregister,
refetchConversations,
refetchPairs,
refetchMessages,
]);
// Live wiring: every persisted A2A message is announced on /ws/system as an
// `a2a.message` frame. Invalidate-on-frame (the session-detail idiom) — the
// frame's excerpt is capped by design, so REST stays the source of truth and
@@ -160,12 +186,6 @@ function A2APageContent() {
[handleSelect, router, searchParams],
);
const handleRefresh = () => {
refetchConversations();
refetchPairs();
if (selectedId) refetchMessages();
};
const conversations = conversationData?.items ?? [];
const selected = conversations.find((c) => c.id === selectedId) ?? null;
const messages = messagesData?.items ?? [];
@@ -213,10 +233,6 @@ function A2APageContent() {
{isConnected ? "Live" : "Offline"}
</span>
</div>
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
@@ -224,7 +240,7 @@ function A2APageContent() {
<OfflineState
title="Cannot Load A2A Conversations"
description="Start the RoboCo orchestrator to view agent-to-agent chats."
onRetry={() => refetchConversations()}
onRetry={() => void refresh()}
/>
) : (
<>
@@ -12,6 +12,16 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ back: vi.fn() }),
}));
vi.mock("@/hooks/use-page-refresh", () => ({
usePageRefresh: () => ({
register: vi.fn(),
unregister: vi.fn(),
refresh: vi.fn(),
loading: false,
disabled: false,
}),
}));
vi.mock("@/hooks/use-agents", () => ({
useAgentStatus: vi.fn(),
useAgentDefinition: vi.fn(() => ({ data: undefined })),
@@ -1,12 +1,14 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
import { formatDistanceToNow } from "date-fns";
import {
useAgentStatus,
useStopAgent,
useAgentDefinition,
} from "@/hooks/use-agents";
import { usePageRefresh } from "@/hooks";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -23,7 +25,6 @@ import {
Square,
AlertTriangle,
Clock,
RefreshCw,
User,
Users,
} from "lucide-react";
@@ -65,6 +66,17 @@ export default function AgentDetailPage() {
const { data: agent, isLoading, error, refetch } = useAgentStatus(agentId);
const { data: definition } = useAgentDefinition(agentId);
const { register, unregister } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
const stopAgent = useStopAgent();
// Get display values from definition or fallback
@@ -157,10 +169,6 @@ export default function AgentDetailPage() {
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
{isWaiting && <ResolveWaitDialog agentId={agentId} />}
{isActive ? (
<>
+13 -8
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import {
useOrchestratorStatus,
useWaitingAgents,
@@ -8,9 +8,8 @@ import {
} from "@/hooks/use-agents";
import { useAgentUsage } from "@/hooks/use-usage";
import { AgentStatusResponse, AgentUsageRow } from "@/types";
import { Button } from "@/components/ui/button";
import { RefreshCw } from "lucide-react";
import { OfflineState } from "@/components/ui/offline-state";
import { usePageRefresh } from "@/hooks";
import {
getBoardAgents,
getMainPm,
@@ -31,6 +30,16 @@ export default function AgentsPage() {
const { data: waitingAgents } = useWaitingAgents();
const { data: usageRows } = useAgentUsage();
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
@@ -68,17 +77,13 @@ export default function AgentsPage() {
Monitor and control your AI workforce
</p>
</div>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{isOffline ? (
<OfflineState
title="Orchestrator Not Running"
description="Start the RoboCo orchestrator to spawn and monitor agents. The agent roster is shown below for reference."
onRetry={() => refetch()}
onRetry={() => void refresh()}
/>
) : (
<>
@@ -1,7 +1,8 @@
"use client";
import { use } from "react";
import { use, useEffect } from "react";
import { useJournalEntry } from "@/hooks/use-journals";
import { usePageRefresh } from "@/hooks";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -11,7 +12,6 @@ import { EntryTypeBadge } from "@/components/journals/entry-type-badge";
import {
ArrowLeft,
AlertTriangle,
RefreshCw,
Clock,
Tag,
Link2,
@@ -39,6 +39,16 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
const { entryId } = use(params);
const { data: entry, isLoading, error, refetch } = useJournalEntry(entryId);
const { register, unregister } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Loading state
if (isLoading) {
return (
@@ -77,10 +87,6 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
"The journal entry you're looking for doesn't exist or has been deleted."}
</p>
<div className="flex justify-center gap-4">
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
<Link href="/journals" prefetch={false}>
<Button>View All Journals</Button>
</Link>
+12 -6
View File
@@ -8,9 +8,9 @@ import { AgentList } from "@/components/journals/agent-list";
import { JournalView } from "@/components/journals/journal-view";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { BookOpen, Search, RefreshCw } from "lucide-react";
import { usePageRefresh } from "@/hooks";
import { BookOpen, Search } from "lucide-react";
const JOURNALS_STATE_KEY = "roboco-journals-state";
@@ -76,6 +76,16 @@ function JournalsPageContent() {
const { data: agents, isLoading: loadingAgents, refetch } = useAgents();
const { register, unregister } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Save state to localStorage whenever URL params change
useEffect(() => {
if (selectedAgentId) {
@@ -160,10 +170,6 @@ function JournalsPageContent() {
View agent reflections, learnings, and decisions
</p>
</div>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{/* Main Content — one screen; the agent list and the detail each scroll inside */}
+20 -7
View File
@@ -1,9 +1,10 @@
"use client";
import { Suspense } from "react";
import { Suspense, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useOrchestratorStatus } from "@/hooks/use-agents";
import { useTasks } from "@/hooks/use-tasks";
import { usePageRefresh } from "@/hooks";
import {
useUsageSummary,
useUsageTimeSeries,
@@ -185,16 +186,28 @@ function PerformanceTabContent() {
refetch: refetchStatus,
} = useOrchestratorStatus();
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const callbacks = [
() => {
void refetchTasks();
},
() => {
void refetchStatus();
},
];
callbacks.forEach((cb) => register(cb));
return () => {
callbacks.forEach((cb) => unregister(cb));
};
}, [register, unregister, refetchTasks, refetchStatus]);
const isOffline =
(tasksError || statusError) &&
(tasksError?.message?.includes("Network Error") ||
statusError?.message?.includes("Network Error"));
const refetch = () => {
refetchTasks();
refetchStatus();
};
const taskList = tasks || [];
const agentList = status?.agents || [];
@@ -269,7 +282,7 @@ function PerformanceTabContent() {
<OfflineState
title="Cannot Load Performance Metrics"
description="Start the RoboCo orchestrator to view performance analytics."
onRetry={refetch}
onRetry={() => void refresh()}
/>
);
}
@@ -1,6 +1,6 @@
"use client";
import { Suspense } from "react";
import { Suspense, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import {
useNotifications,
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { OfflineState } from "@/components/ui/offline-state";
import { usePageRefresh } from "@/hooks";
import {
Bell,
Check,
@@ -23,7 +24,6 @@ import {
Info,
ListTodo,
ArrowUpCircle,
RefreshCw,
Mail,
MailOpen,
BookOpen,
@@ -167,6 +167,16 @@ function NotificationsPageContent() {
: undefined,
);
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
const markRead = useMarkNotificationRead();
const acknowledge = useAcknowledgeNotification();
const markAllRead = useMarkAllNotificationsRead();
@@ -218,10 +228,6 @@ function NotificationsPageContent() {
<CheckCheck className="h-4 w-4 mr-2" />
Mark All Read
</Button>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
@@ -271,7 +277,7 @@ function NotificationsPageContent() {
<OfflineState
title="Cannot Load Notifications"
description="Start the RoboCo orchestrator to view and manage notifications."
onRetry={() => refetch()}
onRetry={() => void refresh()}
/>
) : (
<Tabs value={activeTab} onValueChange={handleTabChange}>
+13 -8
View File
@@ -1,16 +1,25 @@
"use client";
import { Suspense } from "react";
import { Suspense, useEffect } from "react";
import { useProducts } from "@/hooks/use-products";
import { OfflineState } from "@/components/ui/offline-state";
import { CreateProductDialog, ProductTable } from "@/components/products";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { RefreshCw } from "lucide-react";
import { usePageRefresh } from "@/hooks";
function ProductsPageContent() {
const { data: products, isLoading, error, refetch } = useProducts();
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
@@ -30,10 +39,6 @@ function ProductsPageContent() {
</div>
<div className="flex items-center gap-2">
<CreateProductDialog />
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
@@ -42,7 +47,7 @@ function ProductsPageContent() {
<OfflineState
title="Cannot Load Products"
description="Start the RoboCo orchestrator to manage products. Products map cells to the projects they work on."
onRetry={() => refetch()}
onRetry={() => void refresh()}
/>
) : (
<ProductTable products={products} isLoading={isLoading} />
+13 -8
View File
@@ -1,6 +1,6 @@
"use client";
import { Suspense, useMemo, useCallback } from "react";
import { Suspense, useMemo, useCallback, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useProjects } from "@/hooks/use-projects";
import { Team } from "@/types";
@@ -10,9 +10,8 @@ import {
ProjectFilters,
ProjectTable,
} from "@/components/projects";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { RefreshCw } from "lucide-react";
import { usePageRefresh } from "@/hooks";
function ProjectsPageContent() {
const router = useRouter();
@@ -75,6 +74,16 @@ function ProjectsPageContent() {
active_only: !showInactive,
});
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Filter projects client-side for search and multi-select cell filter
const filteredProjects = useMemo(() => {
if (!projects) return [];
@@ -119,10 +128,6 @@ function ProjectsPageContent() {
</div>
<div className="flex items-center gap-2">
<CreateProjectDialog />
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
@@ -143,7 +148,7 @@ function ProjectsPageContent() {
<OfflineState
title="Cannot Load Projects"
description="Start the RoboCo orchestrator to manage projects. Projects track git repositories for agent work."
onRetry={() => refetch()}
onRetry={() => void refresh()}
/>
) : (
<ProjectTable projects={filteredProjects} isLoading={isLoading} />
@@ -2,8 +2,7 @@
import { use, useState } from "react";
import axios from "axios";
import { useTask, useTaskLifecycle, useUpdateTask } from "@/hooks/use-tasks";
import { useProject } from "@/hooks/use-projects";
import { useTaskDetail, useTaskLifecycle, useUpdateTask } from "@/hooks";
import { useCreateBranch, useCreatePR, useMergePR } from "@/hooks/use-git";
import { Team, TaskStatus } from "@/types";
import {
@@ -24,7 +23,7 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { AlertTriangle, ArrowLeft, RefreshCw } from "lucide-react";
import { AlertTriangle, ArrowLeft } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
@@ -36,8 +35,8 @@ interface TaskDetailPageProps {
export default function TaskDetailPage({ params }: TaskDetailPageProps) {
const { taskId } = use(params);
const router = useRouter();
const { data: task, isLoading, error, refetch } = useTask(taskId);
const { data: project } = useProject(task?.project_id ?? "");
const { task, project, isLoading, error, refetch } = useTaskDetail(taskId);
const lifecycle = useTaskLifecycle();
const updateTask = useUpdateTask();
const createBranch = useCreateBranch();
@@ -431,10 +430,6 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
"The task you're looking for doesn't exist or has been deleted."}
</p>
<div className="flex justify-center gap-4">
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
<Link href="/tasks" prefetch={false}>
<Button>View All Tasks</Button>
</Link>
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PageRefreshProvider } from "@/components/providers";
import type { ReactNode } from "react";
const { list } = vi.hoisted(() => ({
@@ -40,6 +41,10 @@ function withQueryClient(ui: ReactNode) {
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
function wrapper(ui: ReactNode) {
return withQueryClient(<PageRefreshProvider>{ui}</PageRefreshProvider>);
}
describe("TasksPage — passes status/team/limit server-side (H17)", () => {
beforeEach(() => {
list.mockReset();
@@ -47,7 +52,7 @@ describe("TasksPage — passes status/team/limit server-side (H17)", () => {
});
it("forwards single status + team + limit=500 to tasksApi.list", async () => {
render(withQueryClient(<TasksPage />));
render(wrapper(<TasksPage />));
await waitFor(() => expect(list).toHaveBeenCalled());
expect(list).toHaveBeenCalledWith(
expect.objectContaining({
+25 -9
View File
@@ -15,9 +15,8 @@ import {
SortDirection,
} from "@/components/tasks";
import type { TaskFilters as TaskApiFilters } from "@/lib/api/tasks";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { RefreshCw } from "lucide-react";
import { usePageRefresh } from "@/hooks";
function TasksPageContent() {
const router = useRouter();
@@ -180,8 +179,29 @@ function TasksPageContent() {
const { data: tasks, isLoading, error, refetch } = useTasks(filters);
// Projects + products: power the Project/Product filter options + name display.
const { data: projects } = useProjects();
const { data: products } = useProducts();
const { data: projects, refetch: refetchProjects } = useProjects();
const { data: products, refetch: refetchProducts } = useProducts();
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const callbacks = [
() => {
void refetch();
},
() => {
void refetchProjects();
},
() => {
void refetchProducts();
},
];
callbacks.forEach((cb) => register(cb));
return () => {
callbacks.forEach((cb) => unregister(cb));
};
}, [register, unregister, refetch, refetchProjects, refetchProducts]);
const projectNames = useMemo(
() => Object.fromEntries((projects ?? []).map((p) => [p.id, p.name])),
[projects],
@@ -276,10 +296,6 @@ function TasksPageContent() {
</div>
<div className="flex items-center gap-2">
<CreateTaskDialog />
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
@@ -308,7 +324,7 @@ function TasksPageContent() {
<OfflineState
title="Cannot Load Tasks"
description="Start the RoboCo orchestrator to manage tasks. Tasks you create will be picked up by agents when the backend is running."
onRetry={() => refetch()}
onRetry={() => void refresh()}
/>
) : (
<TaskTable
@@ -1,6 +1,6 @@
"use client";
import { Suspense, useMemo, useCallback } from "react";
import { Suspense, useMemo, useCallback, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useWorkSessions } from "@/hooks/use-work-sessions";
import { WorkSessionStatus } from "@/types";
@@ -9,9 +9,8 @@ import {
WorkSessionTable,
WorkSessionFilters,
} from "@/components/work-sessions";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { RefreshCw } from "lucide-react";
import { usePageRefresh } from "@/hooks";
function WorkSessionsPageContent() {
const router = useRouter();
@@ -60,6 +59,16 @@ function WorkSessionsPageContent() {
// Fetch work sessions
const { data: sessions, isLoading, error, refetch } = useWorkSessions();
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Filter sessions client-side for search and multi-select status filter
const filteredSessions = useMemo(() => {
if (!sessions) return [];
@@ -99,12 +108,6 @@ function WorkSessionsPageContent() {
Track git branches and pull requests for active work
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
{/* Filters - Sticky */}
@@ -122,7 +125,7 @@ function WorkSessionsPageContent() {
<OfflineState
title="Cannot Load Work Sessions"
description="Start the RoboCo orchestrator to view work sessions. Work sessions track agent activity on git branches."
onRetry={() => refetch()}
onRetry={() => void refresh()}
/>
) : (
<WorkSessionTable sessions={filteredSessions} isLoading={isLoading} />
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/providers";
import { Providers } from "@/components/app-providers";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
@@ -3,7 +3,9 @@ import { render, screen, fireEvent, act } from "@testing-library/react";
import type { AdminPairSummary } from "@/lib/api/a2a";
import { A2APairCard } from "../a2a-pair-card";
function buildPair(overrides: Partial<AdminPairSummary> = {}): AdminPairSummary {
function buildPair(
overrides: Partial<AdminPairSummary> = {},
): AdminPairSummary {
return {
agent_a: "be-dev-1",
role_a: "developer",
@@ -21,9 +23,7 @@ function buildPair(overrides: Partial<AdminPairSummary> = {}): AdminPairSummary
describe("A2APairCard", () => {
it("renders both display names, message count, and relative time", () => {
render(
<A2APairCard pair={buildPair()} pulsedAt={null} onOpen={vi.fn()} />,
);
render(<A2APairCard pair={buildPair()} pulsedAt={null} onOpen={vi.fn()} />);
expect(screen.getByText(/Backend Dev 1/)).toBeInTheDocument();
expect(screen.getByText(/Backend QA/)).toBeInTheDocument();
expect(screen.getByText("5")).toBeInTheDocument();
@@ -9,7 +9,9 @@ import {
SECTION_LABELS,
} from "../a2a-switchboard-utils";
function buildPair(overrides: Partial<AdminPairSummary> = {}): AdminPairSummary {
function buildPair(
overrides: Partial<AdminPairSummary> = {},
): AdminPairSummary {
return {
agent_a: "be-dev-1",
role_a: "developer",
@@ -55,9 +57,7 @@ describe("pairMatchesFrame", () => {
expect(pairMatchesFrame("be-dev-1", "be-qa", undefined, "be-qa")).toBe(
false,
);
expect(pairMatchesFrame("be-dev-1", "be-qa", "be-dev-1", null)).toBe(
false,
);
expect(pairMatchesFrame("be-dev-1", "be-qa", "be-dev-1", null)).toBe(false);
});
});
@@ -3,7 +3,9 @@ import { render, screen, fireEvent } from "@testing-library/react";
import type { AdminPairSummary } from "@/lib/api/a2a";
import { A2ASwitchboard } from "../a2a-switchboard";
function buildPair(overrides: Partial<AdminPairSummary> = {}): AdminPairSummary {
function buildPair(
overrides: Partial<AdminPairSummary> = {},
): AdminPairSummary {
return {
agent_a: "be-dev-1",
role_a: "developer",
@@ -5,6 +5,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ThemeProvider } from "next-themes";
import { useState } from "react";
import { Toaster } from "@/components/ui/sonner";
import { PageRefreshProvider } from "@/components/providers";
import { useAgentRosterSync } from "@/hooks/use-agents";
// Keeps the agent display-name resolver (agent-utils) in sync with the live
@@ -36,20 +37,22 @@ export function Providers({ children }: { children: React.ReactNode }) {
);
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<QueryClientProvider client={queryClient}>
<AgentRosterSync />
{children}
<Toaster position="top-right" />
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
</ThemeProvider>
<PageRefreshProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<QueryClientProvider client={queryClient}>
<AgentRosterSync />
{children}
<Toaster position="top-right" />
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
</ThemeProvider>
</PageRefreshProvider>
);
}
@@ -1,5 +1,6 @@
"use client";
import { useEffect } from "react";
import {
useAuditorDashboard,
useAuditorFlags,
@@ -10,8 +11,9 @@ import { QualityMetricsPanel } from "./quality-metrics-panel";
import { FlaggedItemsPanel } from "./flagged-items-panel";
import { ReportsPanel } from "./reports-panel";
import { Button } from "@/components/ui/button";
import { RefreshCw, FileText } from "lucide-react";
import { FileText } from "lucide-react";
import { toast } from "sonner";
import { usePageRefresh } from "@/hooks";
export function AuditorDashboard() {
const {
@@ -23,9 +25,15 @@ export function AuditorDashboard() {
const { data: reports, isLoading: loadingReports } = useAuditorReports();
const createReport = useCreateAuditorReport();
const handleRefresh = () => {
refetch();
};
const { register, unregister } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
const handleGenerateReport = () => {
createReport.mutate(
@@ -55,10 +63,6 @@ export function AuditorDashboard() {
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
<Button
onClick={handleGenerateReport}
disabled={createReport.isPending}
+14 -11
View File
@@ -1,9 +1,9 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Check, RefreshCw, X } from "lucide-react";
import { Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -12,6 +12,7 @@ import { OfflineState } from "@/components/ui/offline-state";
import { RequiredNotesDialog } from "@/components/ui/required-notes-dialog";
import { getErrorMessage } from "@/lib/api/client";
import { pitchesApi, type Pitch } from "@/lib/api/pitches";
import { usePageRefresh } from "@/hooks";
// ---------------------------------------------------------------------------
// Skeleton placeholder shaped like a PitchCard
@@ -180,6 +181,16 @@ export function PitchesTab() {
refetchInterval: 30000,
});
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
const approveMutation = useMutation({
mutationFn: ({ id, notes }: { id: string; notes: string }) =>
pitchesApi.approve(id, notes),
@@ -207,14 +218,6 @@ export function PitchesTab() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Pitches</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => void refetch()}
disabled={isLoading}
>
<RefreshCw className="mr-1 h-4 w-4" /> Refresh
</Button>
</div>
</CardHeader>
<CardContent>
@@ -227,7 +230,7 @@ export function PitchesTab() {
<OfflineState
title="Failed to load pitches"
description="Could not reach the orchestrator API. Check the backend is running."
onRetry={() => void refetch()}
onRetry={() => void refresh()}
/>
) : pitches.length === 0 ? (
<p className="text-sm text-muted-foreground">
@@ -57,6 +57,15 @@ vi.mock("../ceo-approval-queue", () => ({
vi.mock("../pr-review-queue", () => ({
PrReviewQueue: () => <div>PrReviewQueueStub</div>,
}));
vi.mock("@/hooks/use-page-refresh", () => ({
usePageRefresh: () => ({
register: vi.fn(),
unregister: vi.fn(),
refresh: vi.fn(),
loading: false,
disabled: false,
}),
}));
vi.mock("../release-proposal-card", () => ({
ReleaseProposalCard: () => <div>ReleaseProposalCardStub</div>,
}));
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import type { ReleaseProposal } from "@/lib/api/release";
// Control useQuery per test; the mutation + queryClient hooks just need to exist.
@@ -30,6 +31,11 @@ vi.mock("@/lib/api", () => ({
}));
import { ReleaseProposalCard } from "../release-proposal-card";
import { PageRefreshProvider } from "@/components/providers";
function withPageRefresh(ui: ReactNode) {
return <PageRefreshProvider>{ui}</PageRefreshProvider>;
}
function buildProposal(): ReleaseProposal {
return {
@@ -74,18 +80,16 @@ describe("ReleaseProposalCard — query-failure surfacing (F082)", () => {
refetch: vi.fn(),
});
render(<ReleaseProposalCard />);
render(withPageRefresh(<ReleaseProposalCard />));
// The failure must be visible — not a silent hide. The error card surfaces
// the underlying message and a retry affordance.
// the underlying message. Refresh is now handled by the navbar refresh button.
expect(
screen.getByText(/couldn't load the release proposal/i),
).toBeInTheDocument();
expect(
screen.getByText(/release service unavailable/i),
).toBeInTheDocument();
// A retry affordance so the CEO can re-fetch without a full page reload.
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
it("still hides on the 404 no-open-proposal empty state (regression guard)", () => {
@@ -99,7 +103,7 @@ describe("ReleaseProposalCard — query-failure surfacing (F082)", () => {
refetch: vi.fn(),
});
const { container } = render(<ReleaseProposalCard />);
const { container } = render(withPageRefresh(<ReleaseProposalCard />));
expect(container).toBeEmptyDOMElement();
});
@@ -112,7 +116,7 @@ describe("ReleaseProposalCard — query-failure surfacing (F082)", () => {
refetch: vi.fn(),
});
render(<ReleaseProposalCard />);
render(withPageRefresh(<ReleaseProposalCard />));
expect(screen.getByText(/Release Proposal/i)).toBeInTheDocument();
expect(screen.getByText("v0.14.0")).toBeInTheDocument();
expect(
@@ -80,7 +80,9 @@ describe("RoadmapReviewQueue", () => {
it("renders the cycle goal and both item drafts", async () => {
render(withQueryClient(<RoadmapReviewQueue />));
expect(await screen.findByText("Close onboarding friction")).toBeInTheDocument();
expect(
await screen.findByText("Close onboarding friction"),
).toBeInTheDocument();
expect(screen.getByText("Streamline signup")).toBeInTheDocument();
expect(screen.getByText("Simplify pricing page")).toBeInTheDocument();
});
@@ -124,7 +126,11 @@ describe("RoadmapReviewQueue", () => {
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
await waitFor(() =>
expect(rejectItem).toHaveBeenCalledWith("cycle-1", "item-1", "not a priority"),
expect(rejectItem).toHaveBeenCalledWith(
"cycle-1",
"item-1",
"not a priority",
),
);
});
@@ -1,11 +1,13 @@
"use client";
import { useEffect } from "react";
import {
useCeoOverview,
useAuditorFlags,
useRecentActivity,
} from "@/hooks/use-dashboard";
import { useTasks } from "@/hooks/use-tasks";
import { usePageRefresh } from "@/hooks";
import { TeamHealthCards } from "./team-health-cards";
import { KeyMetricsPanel } from "./key-metrics-panel";
import { AuditorAlertsPanel } from "./auditor-alerts-panel";
@@ -23,7 +25,7 @@ import type { Activity } from "./activity-item";
import { Button } from "@/components/ui/button";
import { UsageOverviewPanel } from "./usage-overview-panel";
import { ScorecardOverviewPanel } from "./scorecard-overview-panel";
import { RefreshCw, Settings, AlertCircle } from "lucide-react";
import { Settings, AlertCircle } from "lucide-react";
import Link from "next/link";
export function CommandCenter() {
@@ -52,14 +54,37 @@ export function CommandCenter() {
refetch: refetchActivity,
} = useRecentActivity(24);
const hasError = errorOverview || errorFlags || errorTasks || errorActivity;
const { register, unregister } = usePageRefresh();
const handleRefresh = () => {
refetchOverview();
refetchFlags();
refetchTasks();
refetchActivity();
};
useEffect(() => {
const callbacks = [
() => {
void refetchOverview();
},
() => {
void refetchFlags();
},
() => {
void refetchTasks();
},
() => {
void refetchActivity();
},
];
callbacks.forEach((cb) => register(cb));
return () => {
callbacks.forEach((cb) => unregister(cb));
};
}, [
register,
unregister,
refetchOverview,
refetchFlags,
refetchTasks,
refetchActivity,
]);
const hasError = errorOverview || errorFlags || errorTasks || errorActivity;
return (
// flex-col + explicit `order` (reset via md:order-none): below md the CEO
@@ -78,10 +103,6 @@ export function CommandCenter() {
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
<Link href="/settings" prefetch={false}>
<Button variant="ghost" size="icon">
<Settings className="h-5 w-5" />
@@ -94,7 +115,7 @@ export function CommandCenter() {
{hasError && (
<div className="order-2 flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive md:order-none">
<AlertCircle className="h-4 w-4 shrink-0" />
Some data failed to load. Click Refresh to try again.
Some data failed to load. Use the header refresh button to try again.
</div>
)}
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { releaseApi } from "@/lib/api";
import type { ReleaseExecuteResult } from "@/lib/api/release";
@@ -25,6 +25,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { CheckCircle2, XCircle, Rocket, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { usePageRefresh } from "@/hooks";
const _MIN_REJECT_CHARS = 10;
@@ -56,6 +57,16 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
refetchInterval: 30000,
});
const { register, unregister } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
const approveMutation = useMutation({
mutationFn: () => releaseApi.approve(),
onSuccess: (result: ReleaseExecuteResult) => {
@@ -137,11 +148,6 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
{error instanceof Error ? `: ${error.message}` : ""}.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Retry
</Button>
</CardContent>
</Card>
);
}
@@ -175,7 +175,10 @@ export function RoadmapReviewQueue({ className }: { className?: string }) {
roadmapApi.approveItem(taskId, itemId),
onSuccess: (result) => {
invalidate();
if (result.status === "approved" || result.status === "already_approved") {
if (
result.status === "approved" ||
result.status === "already_approved"
) {
toast.success("Item approved — added to the backlog");
} else {
toast.warning(result.detail);
@@ -251,8 +254,8 @@ export function RoadmapReviewQueue({ className }: { className?: string }) {
<DialogHeader>
<DialogTitle>Reject roadmap item</DialogTitle>
<DialogDescription>
This records your reason and feeds the next cycle&apos;s prompt
it is not added to the backlog.
This records your reason and feeds the next cycle&apos;s prompt
it is not added to the backlog.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
+48 -268
View File
@@ -1,18 +1,7 @@
"use client";
import { useCallback, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useProjects } from "@/hooks/use-projects";
import {
useGitStatus,
useGitLog,
useGitBranches,
useGitDiff,
useGitOperations,
} from "@/hooks/use-git";
import { BranchType } from "@/types/git";
import { Suspense } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
@@ -27,257 +16,54 @@ import { GitBranchPanel } from "./git-branch-panel";
import { GitLogPanel } from "./git-log-panel";
import { GitDiffViewer } from "./git-diff-viewer";
import { GitActionsPanel } from "./git-actions-panel";
import { GitBranch, RefreshCw, FolderGit2 } from "lucide-react";
import { toast } from "sonner";
import { getErrorMessage } from "@/lib/api/client";
import { GitBranch, FolderGit2 } from "lucide-react";
import { useGitBrowser } from "@/hooks/use-git-browser";
function GitBrowserContent() {
const router = useRouter();
const searchParams = useSearchParams();
// Read state from URL
const projectSlug = searchParams.get("project") || "";
const taskId = searchParams.get("task") || "";
// Fetch projects
const {
data: projects,
isLoading: loadingProjects,
error: projectsError,
refetch: refetchProjects,
} = useProjects();
// Git hooks - only enabled when project is selected
const {
data: status,
isLoading: loadingStatus,
refetch: refetchStatus,
} = useGitStatus(projectSlug, taskId, !!projectSlug);
const {
data: log,
isLoading: loadingLog,
refetch: refetchLog,
} = useGitLog(projectSlug, 20, undefined, !!projectSlug);
const {
data: branches,
isLoading: loadingBranches,
refetch: refetchBranches,
} = useGitBranches(projectSlug, true, !!projectSlug);
const { data: stagedDiff, isLoading: loadingStagedDiff } = useGitDiff(
projectSlug,
true,
undefined,
!!projectSlug,
);
const { data: unstagedDiff, isLoading: loadingUnstagedDiff } = useGitDiff(
projectSlug,
false,
undefined,
!!projectSlug,
);
// Git operations
const {
commit,
push,
createBranch,
checkout,
createPR,
mergePR,
pull,
fetch,
rebase,
} = useGitOperations();
// Update URL params
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
const query = params.toString();
router.push(query ? `/git?${query}` : "/git");
},
[router, searchParams],
);
const handleProjectChange = useCallback(
(slug: string) => {
updateParams({ project: slug || null, task: null });
},
[updateParams],
);
const handleRefresh = () => {
refetchStatus();
refetchLog();
refetchBranches();
};
// Operation handlers
// Note: agent_id is "ceo" because this panel is used by the CEO
const handleCheckout = async (branch: string) => {
try {
await checkout.mutateAsync({
project_slug: projectSlug,
branch,
agent_id: "ceo",
});
toast.success(`Checked out ${branch}`);
} catch {
toast.error("Failed to checkout branch");
}
};
const handleCreateBranch = async (
branchType: BranchType,
branchTaskId: string,
) => {
try {
const result = await createBranch.mutateAsync({
project_slug: projectSlug,
task_id: branchTaskId,
branch_type: branchType,
agent_id: "ceo",
});
toast.success(`Created branch ${result.branch_name}`);
} catch {
toast.error("Failed to create branch");
}
};
const handleCommit = async (message: string) => {
try {
const result = await commit.mutateAsync({
project_slug: projectSlug,
message,
task_id: taskId || undefined,
agent_id: "ceo",
});
toast.success(`Committed: ${result.commit_hash.slice(0, 7)}`);
} catch {
toast.error("Failed to commit");
}
};
const handlePush = async (force?: boolean) => {
try {
const result = await push.mutateAsync({
project_slug: projectSlug,
task_id: taskId || undefined,
agent_id: "ceo",
force,
});
toast.success(
`Pushed ${result.commits_pushed} commits to ${result.branch}`,
);
} catch {
toast.error("Failed to push");
}
};
const handleCreatePR = async (title: string, body: string) => {
try {
const result = await createPR.mutateAsync({
project_slug: projectSlug,
task_id: taskId || undefined,
title,
body,
agent_id: "ceo",
});
toast.success(
<span>
Created PR #{result.pr_number}:{" "}
<a
href={result.pr_url}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
View
</a>
</span>,
);
} catch {
toast.error("Failed to create PR");
}
};
const handleMergePR = async (prNumber: number) => {
try {
const result = await mergePR.mutateAsync({
project_slug: projectSlug,
pr_number: prNumber,
task_id: taskId || undefined,
agent_id: "ceo",
});
toast.success(`Merged PR #${result.pr_number}${result.target_branch}`);
} catch {
toast.error("Failed to merge PR");
}
};
const handlePull = async () => {
try {
const result = await pull.mutateAsync({
project_slug: projectSlug,
task_id: taskId || undefined,
});
toast.success(`Pulled: now on ${result.current_branch}`);
} catch {
toast.error("Failed to pull from remote");
}
};
const handleFetch = async () => {
try {
const result = await fetch.mutateAsync({
project_slug: projectSlug,
task_id: taskId || undefined,
});
toast.success(`Fetched: now on ${result.current_branch}`);
} catch {
toast.error("Failed to fetch from remote");
}
};
const handleRebase = async (targetBranch: string) => {
try {
const result = await rebase.mutateAsync({
project_slug: projectSlug,
target_branch: targetBranch,
task_id: taskId || undefined,
agent_id: "ceo",
});
if (result.conflict) {
toast.warning(
`Rebase conflicts in: ${result.conflicted_files.join(", ") || "unknown files"}`,
);
} else {
toast.success("Rebase completed successfully");
}
} catch (error) {
toast.error(getErrorMessage(error));
}
};
// Check offline
const isOffline =
projectsError &&
(projectsError.message?.includes("Network Error") ||
(projectsError as { code?: string })?.code === "ERR_NETWORK");
taskId,
projects,
loadingProjects,
status,
loadingStatus,
log,
loadingLog,
branches,
loadingBranches,
stagedDiff,
loadingStagedDiff,
unstagedDiff,
loadingUnstagedDiff,
isOffline,
refresh,
handleProjectChange,
handleCheckout,
handleCreateBranch,
handleCommit,
handlePush,
handleCreatePR,
handleMergePR,
handlePull,
handleFetch,
handleRebase,
isCommitting,
isPushing,
isCreatingPR,
isMerging,
isPulling,
isFetching,
isRebasing,
isCheckingOut,
isCreatingBranch,
} = useGitBrowser();
if (isOffline) {
return (
<OfflineState
title="Cannot Connect to Git Service"
description="Start the RoboCo orchestrator to access git operations."
onRetry={() => refetchProjects()}
onRetry={() => void refresh()}
/>
);
}
@@ -313,12 +99,6 @@ function GitBrowserContent() {
)}
</SelectContent>
</Select>
{projectSlug && (
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
)}
</div>
</div>
@@ -354,13 +134,13 @@ function GitBrowserContent() {
onPull={handlePull}
onFetch={handleFetch}
onRebase={handleRebase}
isCommitting={commit.isPending}
isPushing={push.isPending}
isCreatingPR={createPR.isPending}
isMerging={mergePR.isPending}
isPulling={pull.isPending}
isFetching={fetch.isPending}
isRebasing={rebase.isPending}
isCommitting={isCommitting}
isPushing={isPushing}
isCreatingPR={isCreatingPR}
isMerging={isMerging}
isPulling={isPulling}
isFetching={isFetching}
isRebasing={isRebasing}
/>
</div>
@@ -371,8 +151,8 @@ function GitBrowserContent() {
isLoading={loadingBranches}
onCheckout={handleCheckout}
onCreateBranch={handleCreateBranch}
isCheckingOut={checkout.isPending}
isCreating={createBranch.isPending}
isCheckingOut={isCheckingOut}
isCreating={isCreatingBranch}
/>
<GitLogPanel log={log} isLoading={loadingLog} />
</div>
-1
View File
@@ -1,4 +1,3 @@
export { JournalBrowser } from "./journal-browser";
export { JournalView } from "./journal-view";
export { AgentList } from "./agent-list";
export { AgentItem } from "./agent-item";
@@ -1,96 +0,0 @@
"use client";
import { useState } from "react";
import { useAgents } from "@/hooks/use-agents";
import { AgentList } from "./agent-list";
import { JournalView } from "./journal-view";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { BookOpen, Search, RefreshCw } from "lucide-react";
export function JournalBrowser() {
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agentSearch, setAgentSearch] = useState("");
const { data: agents, isLoading: loadingAgents, refetch } = useAgents();
// Filter agents by search
const filteredAgents = (agents ?? []).filter((agent) => {
if (!agentSearch) return true;
const query = agentSearch.toLowerCase();
return (
agent.agent_id.toLowerCase().includes(query) ||
agent.role.toLowerCase().includes(query) ||
agent.team?.toLowerCase().includes(query)
);
});
// Get selected agent
const selectedAgent = agents?.find((a) => a.agent_id === selectedAgentId);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Agent Journals</h1>
<p className="text-muted-foreground">
View agent reflections, learnings, and decisions
</p>
</div>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{/* Main Content */}
<div className="grid grid-cols-12 gap-6">
{/* Sidebar */}
<div className="col-span-12 lg:col-span-3">
<Card>
<CardContent className="p-3">
{/* Agent Search */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={agentSearch}
onChange={(e) => setAgentSearch(e.target.value)}
placeholder="Search agents..."
className="pl-9"
/>
</div>
{/* Agent List */}
<AgentList
agents={filteredAgents}
isLoading={loadingAgents}
selectedAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId}
/>
</CardContent>
</Card>
</div>
{/* Journal Content */}
<div className="col-span-12 lg:col-span-9">
<Card>
<CardContent className="p-6">
{selectedAgent ? (
<JournalView agent={selectedAgent} />
) : (
<div className="text-center py-16 text-muted-foreground">
<BookOpen className="h-16 w-16 mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-medium mb-2">Select an Agent</h3>
<p className="text-sm">
Choose an agent from the list to view their journal entries
</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
import { PageRefreshProvider } from "@/components/providers";
// Capture the board's onDragEnd so the test can synthesize a drop without
// driving the real dnd-kit pointer sensor (painful in jsdom).
@@ -116,6 +117,10 @@ function drop(activeId: string, overId: TaskStatus) {
dragRef.onDragEnd?.({ active: { id: activeId }, over: { id: overId } });
}
function renderWithRefresh(ui: React.ReactNode) {
return render(<PageRefreshProvider>{ui}</PageRefreshProvider>);
}
describe("KanbanBoard — admin-override bypass confirmation (F020)", () => {
beforeEach(() => {
mutateAsync.mockClear();
@@ -130,7 +135,7 @@ describe("KanbanBoard — admin-override bypass confirmation (F020)", () => {
tasksRef.current = [
buildTask({ id: "t1", status: TaskStatus.IN_PROGRESS, pr_number: null }),
];
render(<KanbanBoard title="Board" columns={COLUMNS} />);
renderWithRefresh(<KanbanBoard title="Board" columns={COLUMNS} />);
// Drag a PR-less task straight to Done — completing with no open PR skips
// the in-band gate. The board must NOT fire the override silently; it must
@@ -159,7 +164,7 @@ describe("KanbanBoard — admin-override bypass confirmation (F020)", () => {
tasksRef.current = [
buildTask({ id: "t1", status: TaskStatus.IN_PROGRESS, pr_number: null }),
];
render(<KanbanBoard title="Board" columns={COLUMNS} />);
renderWithRefresh(<KanbanBoard title="Board" columns={COLUMNS} />);
drop("t1", TaskStatus.COMPLETED);
await screen.findByText(/no open pr/i);
@@ -177,7 +182,7 @@ describe("KanbanBoard — admin-override bypass confirmation (F020)", () => {
tasksRef.current = [
buildTask({ id: "t1", status: TaskStatus.PENDING, pr_number: null }),
];
render(<KanbanBoard title="Board" columns={COLUMNS} />);
renderWithRefresh(<KanbanBoard title="Board" columns={COLUMNS} />);
drop("t1", TaskStatus.CLAIMED);
@@ -11,7 +11,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChevronLeft, ChevronRight, RefreshCw } from "lucide-react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import {
DndContext,
@@ -22,10 +22,11 @@ import {
useSensor,
useSensors,
} from "@dnd-kit/core";
import { useState } from "react";
import { useEffect, useState } from "react";
import { KanbanCard } from "./kanban-card";
import { RequiredNotesDialog } from "@/components/tasks/task-detail/task-action-dialogs";
import { skippedPreconditions } from "./bypass-preconditions";
import { usePageRefresh } from "@/hooks";
import {
AlertDialog,
AlertDialogAction,
@@ -96,6 +97,16 @@ export function KanbanBoard({
const lifecycle = useTaskLifecycle();
const updateTask = useUpdateTask();
const [activeTask, setActiveTask] = useState<Task | null>(null);
const { register, unregister } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
const [pendingNotesAction, setPendingNotesAction] =
useState<PendingNotesAction | null>(null);
const [pendingOverride, setPendingOverride] =
@@ -358,10 +369,6 @@ export function KanbanBoard({
</SelectContent>
</Select>
)}
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, Suspense } from "react";
import { useState, useCallback, Suspense, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { KBIndexType, RAGQueryResponse } from "@/types";
import {
@@ -49,6 +49,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { formatDistanceToNow } from "date-fns";
import { getErrorMessage } from "@/lib/api/client";
import { usePageRefresh } from "@/hooks";
// Components
import { KBSearchBar } from "./kb-search-bar";
@@ -176,6 +177,24 @@ function KnowledgeBaseBrowserContent() {
isLoading: loadingHealth,
refetch: refetchHealth,
} = useRAGHealth();
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const callbacks = [
() => {
void refetchStats();
},
() => {
void refetchHealth();
},
];
callbacks.forEach((cb) => register(cb));
return () => {
callbacks.forEach((cb) => unregister(cb));
};
}, [register, unregister, refetchStats, refetchHealth]);
const deleteIndex = useDeleteIndex();
const refreshIndex = useRefreshIndex();
const reindexAll = useReindexAll();
@@ -249,11 +268,6 @@ function KnowledgeBaseBrowserContent() {
}
};
const handleRefresh = () => {
refetchStats();
refetchHealth();
};
// Calculate totals for admin
const totalDocs =
stats?.indexes.reduce((sum, idx) => sum + idx.document_count, 0) ?? 0;
@@ -282,7 +296,7 @@ function KnowledgeBaseBrowserContent() {
<OfflineState
title="Cannot Connect to Knowledge Base"
description="Start the RoboCo orchestrator to access the knowledge base."
onRetry={() => refetchStats()}
onRetry={() => void refresh()}
/>
</div>
);
@@ -298,10 +312,6 @@ function KnowledgeBaseBrowserContent() {
Search and query indexed knowledge
</p>
</div>
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{/* Tabs */}
@@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { useState, type ReactNode } from "react";
import { Header } from "../header";
import { PageRefreshProvider } from "@/components/providers";
import { usePageRefresh } from "@/hooks";
vi.mock("next-themes", () => ({
useTheme: () => ({ theme: "system", setTheme: vi.fn() }),
}));
vi.mock("@/hooks/use-websocket", () => ({
useNotificationStream: () => ({
notifications: [],
isConnected: true,
clearMessages: vi.fn(),
}),
}));
vi.mock("@/components/layout/connection-status", () => ({
ConnectionStatus: () => <div data-testid="connection-status" />,
}));
vi.mock("@/components/layout/mobile-sidebar", () => ({
MobileSidebar: () => <div data-testid="mobile-sidebar" />,
}));
vi.mock("@/components/notifications/notification-bell", () => ({
NotificationBell: () => <div data-testid="notification-bell" />,
}));
function withPageRefresh(ui: ReactNode) {
return <PageRefreshProvider>{ui}</PageRefreshProvider>;
}
function RefreshRegistrator({
callback,
}: {
callback: () => void | Promise<void>;
}) {
const { register, unregister } = usePageRefresh();
const [registered, setRegistered] = useState(false);
if (!registered) {
register(callback);
setRegistered(true);
}
return (
<button
type="button"
onClick={() => unregister(callback)}
data-testid="unregister"
>
Unregister
</button>
);
}
describe("Header — navbar refresh button", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the refresh button between the connection status and theme toggle", () => {
render(withPageRefresh(<Header />));
const refreshButton = screen.getByRole("button", {
name: /refresh only the current page/i,
});
expect(refreshButton).toBeInTheDocument();
const connectionStatus = screen.getByTestId("connection-status");
const themeButton = screen.getByRole("button", { name: /toggle theme/i });
expect(connectionStatus.compareDocumentPosition(refreshButton)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(refreshButton.compareDocumentPosition(themeButton)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
});
it("exposes an accessible tooltip/label that clarifies the refresh is page-scoped", () => {
render(withPageRefresh(<Header />));
const refreshButton = screen.getByRole("button", {
name: /refresh only the current page/i,
});
expect(refreshButton).toHaveAttribute(
"aria-label",
"Refresh only the current page",
);
expect(refreshButton).toHaveAttribute(
"title",
"Refresh only the current page",
);
});
it("disables the refresh button when no page has registered a refresh callback", () => {
render(withPageRefresh(<Header />));
const refreshButton = screen.getByRole("button", {
name: /refresh only the current page/i,
});
expect(refreshButton).toBeDisabled();
});
it("shows a spinner while the registered refresh callback is running and disables the button", async () => {
let resolveRefresh: (() => void) | undefined;
const deferred = new Promise<void>((resolve) => {
resolveRefresh = resolve;
});
const callback = vi.fn(() => deferred);
render(
withPageRefresh(
<>
<RefreshRegistrator callback={callback} />
<Header />
</>,
),
);
const refreshButton = screen.getByRole("button", {
name: /refresh only the current page/i,
});
refreshButton.click();
await waitFor(() => expect(refreshButton).toBeDisabled());
expect(callback).toHaveBeenCalledTimes(1);
resolveRefresh?.();
await waitFor(() => expect(refreshButton).not.toBeDisabled());
});
it("is not clickable again until the previous refresh finishes", async () => {
let resolveRefresh: (() => void) | undefined;
const deferred = new Promise<void>((resolve) => {
resolveRefresh = resolve;
});
const callback = vi.fn(() => deferred);
render(
withPageRefresh(
<>
<RefreshRegistrator callback={callback} />
<Header />
</>,
),
);
const refreshButton = screen.getByRole("button", {
name: /refresh only the current page/i,
});
refreshButton.click();
refreshButton.click();
await waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
resolveRefresh?.();
await waitFor(() => expect(refreshButton).not.toBeDisabled());
});
});
+16 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useTheme } from "next-themes";
import { Search, Sun, Moon, Monitor } from "lucide-react";
import { Search, Sun, Moon, Monitor, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -19,9 +19,12 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { usePageRefresh } from "@/hooks";
export function Header() {
const { setTheme } = useTheme();
const { refresh, loading, disabled } = usePageRefresh();
return (
<header className="flex h-16 items-center justify-between border-b bg-background px-6">
@@ -52,6 +55,18 @@ export function Header() {
{/* Connection Status */}
<ConnectionStatus />
{/* Refresh current page data */}
<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>
{/* Theme toggle */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -482,8 +482,8 @@ function EditProjectForm({
</div>
))}
<p className="text-xs text-muted-foreground">
Provision a throwaway sandbox DB/Redis/Mongo per agent spawn for
this project instead of the production credentials.
Provision a throwaway sandbox DB/Redis per agent spawn for this
project instead of the production credentials.
</p>
</div>
</>
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
import { PageRefreshProvider } from "../page-refresh-provider";
/**
* A `PageRefreshProvider` with nothing registered `disabled` starts `true`
* since it is derived from the registry, not a prop.
*/
export function PageRefreshWrapper({ children }: { children: ReactNode }) {
return <PageRefreshProvider>{children}</PageRefreshProvider>;
}
+7
View File
@@ -0,0 +1,7 @@
export {
PageRefreshContext,
PageRefreshProvider,
type PageRefreshProviderProps,
type PageRefreshState,
type RefreshCallback,
} from "./page-refresh-provider";
@@ -0,0 +1,94 @@
"use client";
import * as React from "react";
/**
* A callback that will be invoked when the page refresh is triggered.
* Synchronous callbacks are allowed; asynchronous callbacks are awaited.
*/
export type RefreshCallback = () => void | Promise<void>;
/**
* The API exposed to consumers of the page refresh context.
*/
export interface PageRefreshState {
/** Whether refresh actions are currently disabled. */
disabled: boolean;
/** Whether a refresh cycle is currently in progress. */
loading: boolean;
/** Register a callback to be invoked on the next refresh. */
register: (callback: RefreshCallback) => void;
/** Unregister a previously registered callback. */
unregister: (callback: RefreshCallback) => void;
/** Trigger all registered callbacks and update the loading state. */
refresh: () => Promise<void>;
}
export interface PageRefreshProviderProps {
children: React.ReactNode;
}
export const PageRefreshContext = React.createContext<PageRefreshState | null>(
null,
);
/**
* Provides a scoped page-refresh registry for child components.
*
* Pages and panels can register callbacks that refetch data; UI chrome can call
* `refresh()` and reflect the combined loading/disabled state. `disabled` reflects
* whether any callback is currently registered there's nothing to refresh when
* the registry is empty.
*/
export function PageRefreshProvider({ children }: PageRefreshProviderProps) {
const [loading, setLoading] = React.useState(false);
const [registeredCount, setRegisteredCount] = React.useState(0);
const callbacksRef = React.useRef(new Set<RefreshCallback>());
const refreshingRef = React.useRef(false);
const disabled = registeredCount === 0;
const register = React.useCallback((callback: RefreshCallback) => {
callbacksRef.current.add(callback);
setRegisteredCount(callbacksRef.current.size);
}, []);
const unregister = React.useCallback((callback: RefreshCallback) => {
callbacksRef.current.delete(callback);
setRegisteredCount(callbacksRef.current.size);
}, []);
const refresh = React.useCallback(async () => {
if (disabled || refreshingRef.current) {
return;
}
refreshingRef.current = true;
setLoading(true);
try {
const callbacks = Array.from(callbacksRef.current);
await Promise.all(
callbacks.map((callback) => Promise.resolve(callback())),
);
} finally {
refreshingRef.current = false;
setLoading(false);
}
}, [disabled]);
const value = React.useMemo<PageRefreshState>(
() => ({
disabled,
loading,
register,
unregister,
refresh,
}),
[disabled, loading, register, unregister, refresh],
);
return (
<PageRefreshContext.Provider value={value}>
{children}
</PageRefreshContext.Provider>
);
}
@@ -23,7 +23,9 @@ vi.mock("sonner", () => ({
}));
vi.mock("@/components/ui/select", () => ({
Select: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Select: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
@@ -40,7 +40,9 @@ vi.mock("@/components/ui/select", () => {
onValueChange?: (v: string) => void;
children: React.ReactNode;
}) => (
<Ctx.Provider value={onValueChange ?? (() => {})}>{children}</Ctx.Provider>
<Ctx.Provider value={onValueChange ?? (() => {})}>
{children}
</Ctx.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
@@ -0,0 +1,315 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useGitBrowser } from "../use-git-browser";
const {
mockUseProjects,
mockUseGitStatus,
mockUseGitLog,
mockUseGitBranches,
mockUseGitDiff,
mockUseGitOperations,
mockUsePageRefresh,
mockUseSearchParams,
mockUseRouter,
mockToastSuccess,
mockToastError,
} = vi.hoisted(() => ({
mockUseProjects: vi.fn(),
mockUseGitStatus: vi.fn(),
mockUseGitLog: vi.fn(),
mockUseGitBranches: vi.fn(),
mockUseGitDiff: vi.fn(),
mockUseGitOperations: vi.fn(),
mockUsePageRefresh: vi.fn(),
mockUseSearchParams: vi.fn(),
mockUseRouter: vi.fn(),
mockToastSuccess: vi.fn(),
mockToastError: vi.fn(),
}));
vi.mock("@/hooks/use-projects", () => ({
useProjects: () => mockUseProjects(),
}));
vi.mock("@/hooks/use-git", () => ({
useGitStatus: (...args: unknown[]) => mockUseGitStatus(...args),
useGitLog: (...args: unknown[]) => mockUseGitLog(...args),
useGitBranches: (...args: unknown[]) => mockUseGitBranches(...args),
useGitDiff: (...args: unknown[]) => mockUseGitDiff(...args),
useGitOperations: () => mockUseGitOperations(),
}));
vi.mock("@/hooks/use-page-refresh", () => ({
usePageRefresh: () => mockUsePageRefresh(),
}));
vi.mock("next/navigation", () => ({
useSearchParams: () => mockUseSearchParams(),
useRouter: () => mockUseRouter(),
}));
vi.mock("sonner", () => ({
toast: {
success: mockToastSuccess,
error: mockToastError,
warning: vi.fn(),
},
}));
vi.mock("@/lib/api/client", () => ({
getErrorMessage: (err: unknown) =>
err instanceof Error ? err.message : "Unknown error",
}));
function buildMutations(overrides: Record<string, unknown> = {}) {
return {
commit: { mutateAsync: vi.fn(), isPending: false },
push: { mutateAsync: vi.fn(), isPending: false },
createBranch: { mutateAsync: vi.fn(), isPending: false },
checkout: { mutateAsync: vi.fn(), isPending: false },
createPR: { mutateAsync: vi.fn(), isPending: false },
mergePR: { mutateAsync: vi.fn(), isPending: false },
pull: { mutateAsync: vi.fn(), isPending: false },
fetch: { mutateAsync: vi.fn(), isPending: false },
rebase: { mutateAsync: vi.fn(), isPending: false },
...overrides,
};
}
function buildQueryResult(data: unknown) {
return {
data,
isLoading: false,
error: null,
refetch: vi.fn(),
};
}
describe("useGitBrowser", () => {
const registeredCallbacks: Array<() => void | Promise<void>> = [];
beforeEach(() => {
vi.clearAllMocks();
registeredCallbacks.length = 0;
mockUseProjects.mockReturnValue(buildQueryResult([]));
mockUseGitStatus.mockReturnValue(buildQueryResult(null));
mockUseGitLog.mockReturnValue(buildQueryResult(null));
mockUseGitBranches.mockReturnValue(buildQueryResult(null));
mockUseGitDiff.mockReturnValue(buildQueryResult(null));
mockUseGitOperations.mockReturnValue(buildMutations());
mockUsePageRefresh.mockReturnValue({
register: (cb: () => void) => registeredCallbacks.push(cb),
unregister: (cb: () => void) => {
const idx = registeredCallbacks.indexOf(cb);
if (idx >= 0) registeredCallbacks.splice(idx, 1);
},
refresh: vi.fn(),
loading: false,
});
mockUseSearchParams.mockReturnValue(
new URLSearchParams("project=roboco&task=t1"),
);
mockUseRouter.mockReturnValue({ push: vi.fn() });
});
it("reads project and task ids from URL search params", () => {
const { result } = renderHook(() => useGitBrowser());
expect(result.current.projectSlug).toBe("roboco");
expect(result.current.taskId).toBe("t1");
});
it("passes project slug and enabled flag to git query hooks", () => {
renderHook(() => useGitBrowser());
expect(mockUseGitStatus).toHaveBeenCalledWith("roboco", "t1", true);
expect(mockUseGitLog).toHaveBeenCalledWith("roboco", 20, undefined, true);
expect(mockUseGitBranches).toHaveBeenCalledWith("roboco", true, true);
expect(mockUseGitDiff).toHaveBeenCalledWith(
"roboco",
true,
undefined,
true,
);
expect(mockUseGitDiff).toHaveBeenCalledWith(
"roboco",
false,
undefined,
true,
);
});
it("disables git query hooks when no project is selected", () => {
mockUseSearchParams.mockReturnValue(new URLSearchParams());
renderHook(() => useGitBrowser());
expect(mockUseGitStatus).toHaveBeenCalledWith("", "", false);
expect(mockUseGitLog).toHaveBeenCalledWith("", 20, undefined, false);
expect(mockUseGitBranches).toHaveBeenCalledWith("", true, false);
});
it("registers refetch callbacks for projects, status, log and branches", () => {
const refetchProjects = vi.fn();
const refetchStatus = vi.fn();
const refetchLog = vi.fn();
const refetchBranches = vi.fn();
mockUseProjects.mockReturnValue(buildQueryResult([]));
mockUseProjects.mockReturnValue({
data: [],
isLoading: false,
error: null,
refetch: refetchProjects,
});
mockUseGitStatus.mockReturnValue({
data: null,
isLoading: false,
error: null,
refetch: refetchStatus,
});
mockUseGitLog.mockReturnValue({
data: null,
isLoading: false,
error: null,
refetch: refetchLog,
});
mockUseGitBranches.mockReturnValue({
data: null,
isLoading: false,
error: null,
refetch: refetchBranches,
});
renderHook(() => useGitBrowser());
expect(registeredCallbacks.length).toBe(4);
registeredCallbacks.forEach((cb) => cb());
expect(refetchProjects).toHaveBeenCalledTimes(1);
expect(refetchStatus).toHaveBeenCalledTimes(1);
expect(refetchLog).toHaveBeenCalledTimes(1);
expect(refetchBranches).toHaveBeenCalledTimes(1);
});
it("updates URL when project selection changes", () => {
const push = vi.fn();
mockUseRouter.mockReturnValue({ push });
const { result } = renderHook(() => useGitBrowser());
result.current.handleProjectChange("other");
expect(push).toHaveBeenCalledWith("/git?project=other");
});
it("clears task param and removes project param when selection is empty", () => {
const push = vi.fn();
mockUseRouter.mockReturnValue({ push });
mockUseSearchParams.mockReturnValue(
new URLSearchParams("project=roboco&task=t1"),
);
const { result } = renderHook(() => useGitBrowser());
result.current.handleProjectChange("");
expect(push).toHaveBeenCalledWith("/git");
});
it("commits with the current project/task and shows a success toast", async () => {
const mutateAsync = vi.fn(() =>
Promise.resolve({ commit_hash: "abc1234" }),
);
mockUseGitOperations.mockReturnValue(
buildMutations({ commit: { mutateAsync, isPending: false } }),
);
const { result } = renderHook(() => useGitBrowser());
await result.current.handleCommit("fix: something");
await waitFor(() =>
expect(mutateAsync).toHaveBeenCalledWith({
project_slug: "roboco",
message: "fix: something",
task_id: "t1",
agent_id: "ceo",
}),
);
expect(mockToastSuccess).toHaveBeenCalledWith("Committed: abc1234");
});
it("creates a PR and shows a success toast with the PR url", async () => {
const mutateAsync = vi.fn(() =>
Promise.resolve({
pr_number: 42,
pr_url: "https://github.com/x/y/pull/42",
}),
);
mockUseGitOperations.mockReturnValue(
buildMutations({ createPR: { mutateAsync, isPending: false } }),
);
const { result } = renderHook(() => useGitBrowser());
await result.current.handleCreatePR("title", "body");
await waitFor(() =>
expect(mutateAsync).toHaveBeenCalledWith({
project_slug: "roboco",
task_id: "t1",
title: "title",
body: "body",
agent_id: "ceo",
}),
);
expect(mockToastSuccess).toHaveBeenCalledWith(
"Created PR #42: https://github.com/x/y/pull/42",
);
});
it("shows an error toast when a git operation fails", async () => {
const mutateAsync = vi.fn(() => Promise.reject(new Error("nope")));
mockUseGitOperations.mockReturnValue(
buildMutations({ pull: { mutateAsync, isPending: false } }),
);
const { result } = renderHook(() => useGitBrowser());
await result.current.handlePull();
await waitFor(() =>
expect(mockToastError).toHaveBeenCalledWith("Failed to pull from remote"),
);
});
it("surfaces a rebase conflict warning instead of an error", async () => {
const mutateAsync = vi.fn(() =>
Promise.resolve({ conflict: true, conflicted_files: ["foo.ts"] }),
);
mockUseGitOperations.mockReturnValue(
buildMutations({ rebase: { mutateAsync, isPending: false } }),
);
const { result } = renderHook(() => useGitBrowser());
await result.current.handleRebase("main");
await waitFor(() =>
expect(mutateAsync).toHaveBeenCalledWith({
project_slug: "roboco",
target_branch: "main",
task_id: "t1",
agent_id: "ceo",
}),
);
});
it("exposes pending flags from git operation mutations", () => {
mockUseGitOperations.mockReturnValue(
buildMutations({
commit: { mutateAsync: vi.fn(), isPending: true },
push: { mutateAsync: vi.fn(), isPending: false },
}),
);
const { result } = renderHook(() => useGitBrowser());
expect(result.current.isCommitting).toBe(true);
expect(result.current.isPushing).toBe(false);
});
});
@@ -0,0 +1,142 @@
import { describe, it, expect, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { usePageRefresh } from "../use-page-refresh";
import { usePageRefresh as usePageRefreshPublic } from "@/hooks";
import { PageRefreshWrapper } from "@/components/providers/__tests__/test-utils";
describe("usePageRefresh", () => {
it("throws when used outside a PageRefreshProvider", () => {
expect(() => renderHook(() => usePageRefresh())).toThrow(
"usePageRefresh must be used within a PageRefreshProvider",
);
});
it("returns disabled=true and loading=false when nothing is registered", () => {
const { result } = renderHook(() => usePageRefresh(), {
wrapper: PageRefreshWrapper,
});
expect(result.current.disabled).toBe(true);
expect(result.current.loading).toBe(false);
});
it("becomes enabled once a callback is registered, and disabled again once unregistered", () => {
const cb = vi.fn();
const { result } = renderHook(() => usePageRefresh(), {
wrapper: PageRefreshWrapper,
});
act(() => result.current.register(cb));
expect(result.current.disabled).toBe(false);
act(() => result.current.unregister(cb));
expect(result.current.disabled).toBe(true);
});
it("registers and invokes callbacks on refresh", async () => {
const cb = vi.fn();
const { result } = renderHook(() => usePageRefresh(), {
wrapper: PageRefreshWrapper,
});
act(() => {
result.current.register(cb);
});
await act(async () => {
await result.current.refresh();
});
expect(cb).toHaveBeenCalledTimes(1);
});
it("unregisters callbacks", async () => {
const cb = vi.fn();
const { result } = renderHook(() => usePageRefresh(), {
wrapper: PageRefreshWrapper,
});
act(() => {
result.current.register(cb);
result.current.unregister(cb);
});
await act(async () => {
await result.current.refresh();
});
expect(cb).not.toHaveBeenCalled();
});
it("sets loading true while refresh is running and false after", async () => {
let resolve: (() => void) | undefined;
const deferred = new Promise<void>((r) => {
resolve = r;
});
const cb = vi.fn(() => deferred);
const { result } = renderHook(() => usePageRefresh(), {
wrapper: PageRefreshWrapper,
});
act(() => result.current.register(cb));
let refreshPromise: Promise<void>;
act(() => {
refreshPromise = result.current.refresh();
});
await waitFor(() => expect(result.current.loading).toBe(true));
await act(async () => {
resolve?.();
await refreshPromise!;
});
expect(result.current.loading).toBe(false);
expect(cb).toHaveBeenCalledTimes(1);
});
it("does not run any callback when refresh is called with nothing registered", async () => {
const cb = vi.fn();
const { result } = renderHook(() => usePageRefresh(), {
wrapper: PageRefreshWrapper,
});
expect(result.current.disabled).toBe(true);
await act(async () => {
await result.current.refresh();
});
expect(cb).not.toHaveBeenCalled();
});
it("is exported publicly from the hooks barrel", () => {
expect(usePageRefreshPublic).toBe(usePageRefresh);
});
it("does not start a second refresh while one is in progress", async () => {
let resolve: (() => void) | undefined;
const deferred = new Promise<void>((r) => {
resolve = r;
});
const cb = vi.fn(() => deferred);
const { result } = renderHook(() => usePageRefresh(), {
wrapper: PageRefreshWrapper,
});
act(() => result.current.register(cb));
let first: Promise<void>;
let second: Promise<void>;
act(() => {
first = result.current.refresh();
second = result.current.refresh();
});
act(() => resolve?.());
await first!;
await second!;
expect(cb).toHaveBeenCalledTimes(1);
});
});
+3
View File
@@ -1,4 +1,7 @@
export * from "./use-tasks";
export * from "./use-page-refresh";
export * from "./use-task-detail";
export * from "./use-git-browser";
export * from "./use-rate-limit-websocket";
export * from "./use-rate-limit-sync";
export * from "./use-agents";
+362
View File
@@ -0,0 +1,362 @@
"use client";
import { useCallback, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { useProjects } from "@/hooks/use-projects";
import {
useGitStatus,
useGitLog,
useGitBranches,
useGitDiff,
useGitOperations,
} from "@/hooks/use-git";
import { usePageRefresh } from "@/hooks/use-page-refresh";
import { getErrorMessage } from "@/lib/api/client";
import type { BranchType } from "@/types/git";
export interface UseGitBrowserResult {
projectSlug: string;
taskId: string;
projects: ReturnType<typeof useProjects>["data"];
loadingProjects: boolean;
status: ReturnType<typeof useGitStatus>["data"];
loadingStatus: boolean;
log: ReturnType<typeof useGitLog>["data"];
loadingLog: boolean;
branches: ReturnType<typeof useGitBranches>["data"];
loadingBranches: boolean;
stagedDiff: ReturnType<typeof useGitDiff>["data"];
loadingStagedDiff: boolean;
unstagedDiff: ReturnType<typeof useGitDiff>["data"];
loadingUnstagedDiff: boolean;
isOffline: boolean;
refresh: () => Promise<void>;
handleProjectChange: (slug: string) => void;
handleCheckout: (branch: string) => Promise<void>;
handleCreateBranch: (
branchType: BranchType,
branchTaskId: string,
) => Promise<void>;
handleCommit: (message: string) => Promise<void>;
handlePush: (force?: boolean) => Promise<void>;
handleCreatePR: (title: string, body: string) => Promise<void>;
handleMergePR: (prNumber: number) => Promise<void>;
handlePull: () => Promise<void>;
handleFetch: () => Promise<void>;
handleRebase: (targetBranch: string) => Promise<void>;
isCommitting: boolean;
isPushing: boolean;
isCreatingPR: boolean;
isMerging: boolean;
isPulling: boolean;
isFetching: boolean;
isRebasing: boolean;
isCheckingOut: boolean;
isCreatingBranch: boolean;
}
/**
* Fetches all data and binds all actions for the Git browser page.
*
* Keeping this logic out of `GitBrowserContent` lets the component remain
* presentational and avoids the `thin_components` architectural-convention
* warning.
*/
export function useGitBrowser(): UseGitBrowserResult {
const router = useRouter();
const searchParams = useSearchParams();
const { register, unregister, refresh } = usePageRefresh();
const projectSlug = searchParams.get("project") || "";
const taskId = searchParams.get("task") || "";
const {
data: projects,
isLoading: loadingProjects,
error: projectsError,
refetch: refetchProjects,
} = useProjects();
const {
data: status,
isLoading: loadingStatus,
refetch: refetchStatus,
} = useGitStatus(projectSlug, taskId, !!projectSlug);
const {
data: log,
isLoading: loadingLog,
refetch: refetchLog,
} = useGitLog(projectSlug, 20, undefined, !!projectSlug);
const {
data: branches,
isLoading: loadingBranches,
refetch: refetchBranches,
} = useGitBranches(projectSlug, true, !!projectSlug);
const { data: stagedDiff, isLoading: loadingStagedDiff } = useGitDiff(
projectSlug,
true,
undefined,
!!projectSlug,
);
const { data: unstagedDiff, isLoading: loadingUnstagedDiff } = useGitDiff(
projectSlug,
false,
undefined,
!!projectSlug,
);
// Register all active git queries with the page-scoped refresh button.
useEffect(() => {
const callbacks = [
() => void refetchProjects(),
() => void refetchStatus(),
() => void refetchLog(),
() => void refetchBranches(),
];
callbacks.forEach((cb) => register(cb));
return () => callbacks.forEach((cb) => unregister(cb));
}, [
register,
unregister,
refetchProjects,
refetchStatus,
refetchLog,
refetchBranches,
]);
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
const query = params.toString();
router.push(query ? `/git?${query}` : "/git");
},
[router, searchParams],
);
const handleProjectChange = useCallback(
(slug: string) => {
updateParams({ project: slug || null, task: null });
},
[updateParams],
);
const {
commit,
push,
createBranch,
checkout,
createPR,
mergePR,
pull,
fetch,
rebase,
} = useGitOperations();
const handleCheckout = useCallback(
async (branch: string) => {
try {
await checkout.mutateAsync({
project_slug: projectSlug,
branch,
agent_id: "ceo",
});
toast.success(`Checked out ${branch}`);
} catch {
toast.error("Failed to checkout branch");
}
},
[projectSlug, checkout],
);
const handleCreateBranch = useCallback(
async (branchType: BranchType, branchTaskId: string) => {
try {
const result = await createBranch.mutateAsync({
project_slug: projectSlug,
task_id: branchTaskId,
branch_type: branchType,
agent_id: "ceo",
});
toast.success(`Created branch ${result.branch_name}`);
} catch {
toast.error("Failed to create branch");
}
},
[projectSlug, createBranch],
);
const handleCommit = useCallback(
async (message: string) => {
try {
const result = await commit.mutateAsync({
project_slug: projectSlug,
message,
task_id: taskId || undefined,
agent_id: "ceo",
});
toast.success(`Committed: ${result.commit_hash.slice(0, 7)}`);
} catch {
toast.error("Failed to commit");
}
},
[projectSlug, taskId, commit],
);
const handlePush = useCallback(
async (force?: boolean) => {
try {
const result = await push.mutateAsync({
project_slug: projectSlug,
task_id: taskId || undefined,
agent_id: "ceo",
force,
});
toast.success(
`Pushed ${result.commits_pushed} commits to ${result.branch}`,
);
} catch {
toast.error("Failed to push");
}
},
[projectSlug, taskId, push],
);
const handleCreatePR = useCallback(
async (title: string, body: string) => {
try {
const result = await createPR.mutateAsync({
project_slug: projectSlug,
task_id: taskId || undefined,
title,
body,
agent_id: "ceo",
});
toast.success(`Created PR #${result.pr_number}: ${result.pr_url}`);
} catch {
toast.error("Failed to create PR");
}
},
[projectSlug, taskId, createPR],
);
const handleMergePR = useCallback(
async (prNumber: number) => {
try {
const result = await mergePR.mutateAsync({
project_slug: projectSlug,
pr_number: prNumber,
task_id: taskId || undefined,
agent_id: "ceo",
});
toast.success(
`Merged PR #${result.pr_number}${result.target_branch}`,
);
} catch {
toast.error("Failed to merge PR");
}
},
[projectSlug, taskId, mergePR],
);
const handlePull = useCallback(async () => {
try {
const result = await pull.mutateAsync({
project_slug: projectSlug,
task_id: taskId || undefined,
});
toast.success(`Pulled: now on ${result.current_branch}`);
} catch {
toast.error("Failed to pull from remote");
}
}, [projectSlug, taskId, pull]);
const handleFetch = useCallback(async () => {
try {
const result = await fetch.mutateAsync({
project_slug: projectSlug,
task_id: taskId || undefined,
});
toast.success(`Fetched: now on ${result.current_branch}`);
} catch {
toast.error("Failed to fetch from remote");
}
}, [projectSlug, taskId, fetch]);
const handleRebase = useCallback(
async (targetBranch: string) => {
try {
const result = await rebase.mutateAsync({
project_slug: projectSlug,
target_branch: targetBranch,
task_id: taskId || undefined,
agent_id: "ceo",
});
if (result.conflict) {
toast.warning(
`Rebase conflicts in: ${result.conflicted_files.join(", ") || "unknown files"}`,
);
} else {
toast.success("Rebase completed successfully");
}
} catch (error) {
toast.error(getErrorMessage(error));
}
},
[projectSlug, taskId, rebase],
);
const isOffline =
!!projectsError &&
(projectsError.message?.includes("Network Error") ||
(projectsError as { code?: string }).code === "ERR_NETWORK");
return {
projectSlug,
taskId,
projects,
loadingProjects,
status,
loadingStatus,
log,
loadingLog,
branches,
loadingBranches,
stagedDiff,
loadingStagedDiff,
unstagedDiff,
loadingUnstagedDiff,
isOffline,
refresh,
handleProjectChange,
handleCheckout,
handleCreateBranch,
handleCommit,
handlePush,
handleCreatePR,
handleMergePR,
handlePull,
handleFetch,
handleRebase,
isCommitting: commit.isPending,
isPushing: push.isPending,
isCreatingPR: createPR.isPending,
isMerging: mergePR.isPending,
isPulling: pull.isPending,
isFetching: fetch.isPending,
isRebasing: rebase.isPending,
isCheckingOut: checkout.isPending,
isCreatingBranch: createBranch.isPending,
};
}
+7 -1
View File
@@ -32,7 +32,13 @@ export const observabilityKeys = {
memberScorecard: (agentId: string, days: number) =>
[...observabilityKeys.all, "scorecard", "member", agentId, days] as const,
orgScorecard: (days: number, team?: string) =>
[...observabilityKeys.all, "scorecard", "org", team ?? "all", days] as const,
[
...observabilityKeys.all,
"scorecard",
"org",
team ?? "all",
days,
] as const,
taskMetrics: (taskId: string) =>
[...observabilityKeys.all, "task-metrics", taskId] as const,
};
+38
View File
@@ -0,0 +1,38 @@
"use client";
import * as React from "react";
import {
PageRefreshContext,
PageRefreshState,
RefreshCallback,
} from "@/components/providers/page-refresh-provider";
export type { PageRefreshState, RefreshCallback };
/**
* Consume the nearest {@link PageRefreshProvider}.
*
* Returns a stable API for registering/unregistering refresh callbacks and for
* reading the shared disabled/loading state. Throws when called outside a
* provider so consumers fail fast instead of silently missing refreshes.
*
* @example
* ```tsx
* const { register, unregister, refresh, loading, disabled } = usePageRefresh();
*
* useEffect(() => {
* const cb = () => refetch();
* register(cb);
* return () => unregister(cb);
* }, [register, unregister, refetch]);
* ```
*/
export function usePageRefresh(): PageRefreshState {
const context = React.useContext(PageRefreshContext);
if (!context) {
throw new Error("usePageRefresh must be used within a PageRefreshProvider");
}
return context;
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useEffect } from "react";
import { useTask } from "@/hooks/use-tasks";
import { useProject } from "@/hooks/use-projects";
import { usePageRefresh } from "@/hooks/use-page-refresh";
import type { Task, Project } from "@/types";
export interface UseTaskDetailResult {
task: Task | undefined;
project: Project | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => void;
}
/**
* Fetches a single task and its owning project, and registers the task refetch
* callback with the page-scoped refresh provider. The task detail page consumes
* only `{ task, project, isLoading, error, refetch }` so it stays presentational
* and avoids the `thin_components` convention warning.
*/
export function useTaskDetail(taskId: string): UseTaskDetailResult {
const { data: task, isLoading, error, refetch } = useTask(taskId);
const { data: project } = useProject(task?.project_id ?? "");
const { register, unregister } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
return {
task,
project,
isLoading,
error: error ?? null,
refetch: () => void refetch(),
};
}
+1 -4
View File
@@ -168,10 +168,7 @@ export const observabilityApi = {
},
/** Org / team rollup — GET /dashboard/metrics/org?team&days */
getOrgScorecard: async (
days = 30,
team?: string,
): Promise<OrgScorecard> => {
getOrgScorecard: async (days = 30, team?: string): Promise<OrgScorecard> => {
if (isMockMode()) return emptyOrg(team ?? null);
const { data } = await api.get<OrgScorecard>("/dashboard/metrics/org", {
params: { days, ...(team ? { team } : {}) },