AGENTS.md@crates/librefang-api/dashboard · git:20260525.7b130dd · 2026-05-25 · sha256 b7c4c0ef338c208f
AGENTS.md@crates/librefang-api/dashboard git:20260525.7b130ddA
Immutable. This exact content is served forever at /api/v1/blob/b7c4c0ef338c208f.
# Dashboard — Agent Instructions
React 19 + TanStack Router v1 + TanStack Query v5 SPA. Entry: `src/main.tsx`. Pages in `src/pages/`.
## Data layer — mandatory rules
All data access from pages/components goes through the shared hooks layer. Do NOT call `fetch()` or `api.*` directly inside a page or component file.
### Layout
```
src/lib/
http/
client.ts # thin wrapper over src/api.ts + typed re-exports
errors.ts # ApiError class used by the wrapper
queries/
keys.ts # all query-key factories — edit here when adding a domain
keys.test.ts # smoke tests — add cases when you add a factory
<domain>.ts # queryOptions + useXxx hooks per domain
mutations/
<domain>.ts # useXxx mutation hooks with invalidation
```
Domain files today: `agents`, `analytics`, `approvals`, `channels`, `config`, `goals`, `hands`, `mcp`, `media`, `memory`, `models`, `network`, `overview`, `plugins`, `providers`, `runtime`, `schedules`, `sessions`, `skills`, `workflows`.
### Adding a new endpoint
1. Add the raw call in `src/api.ts` (or re-export via `src/lib/http/client.ts`).
2. If it is a new domain, add a factory in `src/lib/queries/keys.ts` following the hierarchical pattern:
```ts
export const fooKeys = {
all: ["foo"] as const,
lists: () => [...fooKeys.all, "list"] as const,
list: (filters: FooFilters = {}) => [...fooKeys.lists(), filters] as const,
details: () => [...fooKeys.all, "detail"] as const,
detail: (id: string) => [...fooKeys.details(), id] as const,
};
```
Every sub-key MUST be anchored with `...fooKeys.all` so broad invalidation works.
3. Add the query in `src/lib/queries/<domain>.ts`:
```ts
export const fooQueryOptions = (filters?: FooFilters) =>
queryOptions({
queryKey: fooKeys.list(filters ?? {}),
queryFn: () => listFoo(filters),
staleTime: 30_000,
});
export function useFoo(filters?: FooFilters) {
return useQuery(fooQueryOptions(filters));
}
```
4. Add mutations in `src/lib/mutations/<domain>.ts`. **Every write MUST invalidate**, and invalidation MUST live inside the hook. (Call sites MAY additionally attach per-call `onSuccess` / `onError` for UI feedback — see the Conventions section.) **Prefer the narrowest matching keys. Use `fooKeys.all` only when the mutation truly dirties every sub-key in the domain.**
Pick the narrowest set that covers what actually changed:
- `fooKeys.detail(id)` + `fooKeys.lists()` — per-id update where the list projection also changes (patch, rename, status flag surfaced in the list row). This is the **default** template.
- `fooKeys.lists()` — list-shape change with no existing detail to refresh (create, delete, reorder).
- `fooKeys.detail(id)` or a nested sub-key like `fooKeys.experiments(id)` — change is genuinely scoped to one detail or one nested collection and the list projection is unaffected.
- `fooKeys.all` — bulk import / cache reset / cross-cutting schema migration. Not the default.
Fan-out trade-off: invalidating `fooKeys.all` while N items are cached refetches the list plus every cached sub-key (`detail(id)`, plus any nested keys like `sessions(id)`, `experiments(id)`) for each of the N items. Use it only when that is the desired effect; otherwise prefer the narrower keys.
```ts
// Default: per-id patch where the list projection also changes.
// Matches shipped usePatchAgentConfig, experiment mutations, etc.
export function useUpdateFoo() {
const qc = useQueryClient();
return useMutation({
mutationFn: updateFoo,
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: fooKeys.lists() });
qc.invalidateQueries({ queryKey: fooKeys.detail(variables.id) });
},
});
}
// Lists-only: membership changed, no existing detail is stale.
export function useCreateFoo() {
const qc = useQueryClient();
return useMutation({
mutationFn: createFoo,
onSuccess: () => qc.invalidateQueries({ queryKey: fooKeys.lists() }),
});
}
// Bulk import / cache reset — NOT the default template.
// Every cached Foo and its sub-keys are potentially stale.
export function useImportFoos() {
const qc = useQueryClient();
return useMutation({
mutationFn: importFoos,
onSuccess: () => qc.invalidateQueries({ queryKey: fooKeys.all }),
});
}
```
5. Update `src/lib/queries/keys.test.ts` — at minimum add the new factory to the `all factories exist` list. Add anchoring/hierarchy tests for non-trivial factories.
### Consuming in pages
```tsx
import { useFoo } from "../lib/queries/foo";
import { useCreateFoo } from "../lib/mutations/foo";
function FooPage() {
const { data, isLoading } = useFoo({ active: true });
const createFoo = useCreateFoo();
// ...
}
```
Never build a `queryKey` inline — always call the factory. Never subscribe to the same endpoint with a different key just to get a subset; use `select` on the shared `queryOptions`.
### Exceptions (not cached data)
Streaming / SSE, imperative fire-and-forget control channels (e.g. `src/components/TerminalTabs.tsx` terminal window lifecycle), and one-shot probes that must not be cached may call `fetch` directly. Keep these narrow and comment why.
## Build & verify
```bash
pnpm lint # eslint — must be green (errors fail; warnings allowed)
pnpm typecheck # tsc --noEmit — must be green
pnpm test --run # vitest — all tests pass
pnpm build # vite build — must succeed
```
Run all four after any change to `src/lib/queries/`, `src/lib/mutations/`, or `src/api.ts`. A passing typecheck alone is not enough — the key-factory tests catch anchoring regressions that the compiler does not.
### Lint policy (refs #5561)
`eslint.config.js` uses ESLint 9 flat config. The load-bearing rules
are `react/jsx-no-target-blank` and `react/no-danger-with-children` —
both `error`. Noisy or pre-existing baseline violations
(`react-hooks/rules-of-hooks`, `no-unused-expressions`,
`no-irregular-whitespace`, `no-control-regex`) are demoted to `warn`
for the initial bootstrap; clean them up incrementally rather than
adding `eslint-disable` comments. When adding a new rule, prefer
flipping it to `error` only after the existing violations are zero.
## Conventions
- TypeScript strict. No `any` in new hooks; lean on types from `src/api.ts` — the canonical, hand-maintained type source consumed by the SPA. `openapi/generated.ts` is a regenerable cross-reference only (not imported; refresh via `pnpm openapi:types`), do not import from it.
- Hooks set sensible defaults in `queryOptions` (shared `staleTime` / `refetchInterval` so consumers without special needs inherit one policy). Accept an optional `options: { enabled?; staleTime?; refetchInterval? }` argument and pass it through to `useQuery` so call sites can override per-page needs — bell-icon polls fast but gated, bulk-management pages poll slowly, tabs gate by active-tab. See `useApprovals({ enabled: open })`, `useCommsEvents(50, { refetchInterval: 5_000 })`, `useModels({}, { enabled: isModelArg })`, `useAgentTemplates({ enabled })`, and `useApprovalCount({ refetchInterval: 5_000 })` for reference shapes. Every call-site override carries a short inline comment explaining why.
```ts
type UseFooOptions = {
enabled?: boolean;
staleTime?: number;
refetchInterval?: number | false;
};
export function useFoo(filters?: FooFilters, options: UseFooOptions = {}) {
const { enabled, staleTime, refetchInterval } = options;
return useQuery({
...fooQueryOptions(filters),
enabled,
staleTime,
refetchInterval,
});
}
```
- Mutation invalidation lives in the hook — callers should never need to know which keys a mutation touches. Call sites MAY attach per-call `onSuccess` / `onError` handlers for UI feedback (toasts, modal dismissal, local state updates); that is orthogonal to invalidation and stays at the call site. See `MemoryPage` delete/cleanup and `ChannelsPage` configure/test for the pattern.
- Commit convention matches the root repo: `feat(dashboard/<area>): ...`, `refactor(dashboard/queries): ...`, `fix(dashboard/<area>): ...`. Never include a `Co-Authored-By` footer.