Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .agents/skills/tanstack-fullstack-pattern/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ An interface-first fullstack architecture built on TanStack Start. The pattern d
23. Latest dependencies: install and keep dependencies at latest compatible versions. Never pin exact versions unless a known incompatibility exists. Use `pnpm add <pkg>` (no version suffix); run `pnpm outdated` and `pnpm update` to align the lockfile.
24. Ask for LLM provider: when scaffolding a new project or when the user's LLM preference is unclear, ask which provider they want before writing the adapter. Install only the chosen `@tanstack/ai-*` adapter package and configure matching env vars. Default is `@tanstack/ai-openai`; do not assume OpenAI without asking. See AGENTS.md section 8 for the full provider table.
25. Generate the system prompt: when scaffolding a new app, ask the user about their domain — entities, capabilities, and permissions — then generate a tailored `BASE_SYSTEM_PROMPT` in `src/routes/api/chat.ts` with six sections (Capabilities, Data Model, Links and navigation, Mutations and data refresh, Permissions and errors, Guidelines). Do not reuse the template's task-management prompt. `buildSystemPrompt()` composes this base with dynamic context (rule 14) and the navigation manifest. `chat()` from `@tanstack/ai` receives it via `systemPrompts: string[]`. See AGENTS.md section 8 "System Prompt Generation" for the full template.
26. Repository-resolved authorization: `authMiddleware` extracts JWT claims **and** calls a repository method (e.g. `getReadRepository().getUserAccess(email)`) to enrich `AuthContext` with application-defined access data — roles, group memberships, owned scopes, superuser flags. Downstream guards (`requireAuth`, `requireGroup`, any app-specific `requireOwnerOf`) and AI tools read this enriched context so UI and AI see the same permission signals. Authorization checks live **inside** server-function handlers (not only in UI components), so permissions are enforced regardless of whether the caller is the UI, the AI, or a direct HTTP client.
27. Write attribution via traceability context: `WritableRepository` methods accept an optional `TraceabilityContext` (`createdBy`, `createdDate`, `lastModifiedBy`, `lastModifiedDate`) built from the authenticated identity. Mutation server-function handlers construct it from `ctx.context.user.email` (available after `requireAuthMiddleware`) and pass it to the repository. Seed and production implementations apply it consistently. This gives UI and AI callers the same audit trail without duplicating logic at each call site.
29. Explicit agent loop depth: configure `agentLoopStrategy: maxIterations(N)` explicitly on the `chat()` call (default N=10). This caps the number of consecutive tool-calling iterations the AI can run before returning a final answer, which bounds latency, cost, and infinite-loop risk. Tune N only after measuring; do not rely on the framework default.
30. Public runtime config bridge: expose non-secret runtime config (Sentry DSN, environment name, feature flags) via a GET server function `getPublicEnv()` and inline the result as `window.__ENV__` in the root `RootDocument` using a small `<script>` tag emitted before client JS runs. Escape `<` in the inlined JSON to avoid breaking the HTML parser. Never rely on `import.meta.env` alone for values that must differ across runtime environments built from the same bundle. See AGENTS.md section "Public Runtime Config" for the template.
31. Router UX defaults bundle: in `src/router.tsx`, configure `defaultStaleTime` (long for read-heavy dashboards, short for mutation-heavy apps), `defaultPreload: 'intent'`, `defaultPreloadStaleTime: 0` (always-fresh preloads), `scrollRestoration: true` (with a `getScrollRestorationKey` when needed), and a `notFoundComponent` on the root route. These defaults are a single coherent bundle — do not ship a router config without them.
32. Link wrapper preserves search: export a project-local `Link` component (e.g. `src/components/Link/Link.tsx`) that wraps TanStack Router's `Link` with `search: true` as the default. Use this wrapper for every internal link so filters, tabs, and other URL state never silently drop on navigation. Reserve raw `<a>` for external URLs.
34. Sentry user context + feedback: when Sentry is enabled, bind the signed-in user via `Sentry.setUser({ email, username })` from the shell component as soon as the identity loads (no extra round-trip).
35. Single markdown artifact for help + AI + suggested prompts: maintain one `docs/help.md` imported with `?raw`. Back three surfaces from it: (a) a `/help` route that renders it with `react-markdown`; (b) an AI tool that returns the content so the assistant can answer "how do I..." questions; (c) a parser that extracts `- [ ]` / `- [x]` lines as the chat's recommended-question list. Zero duplication between docs, assistant, and suggested prompts.
36. Distinct-value filter discovery: for every enum-ish field, the repository exposes a `getDistinctValues(field)` method that flows through a GET server function into a read-only AI tool (`getDistinctStatuses`, `getDistinctCategories`, …). The AI calls these to ground filter values in real data instead of guessing. The root loader can preload the same lists for UI filter bars so UI and AI share one vocabulary.

## Schema Boundaries

Expand Down
126 changes: 123 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ If your team prefers [`@tabler/icons-react`](https://tabler.io/icons) (the Manti
- **Correct**: `const { filter } = Route.useSearch()` — state comes from the URL.
- **Incorrect**: `const [filter, setFilter] = useState('all')` — state trapped in component.
- **Internal Links**: Use TanStack Router's `Link` component for internal navigation.
- **`satisfies` over `as`**: Do not use `as` to bypass type checking (`as never`, `as Record<string, unknown>`, `as SomeProps`). Prefer `as const satisfies SomeSchema` for literals, and prefer schema runtime parsing (`Schema.parse`/`safeParse`) at boundaries where data comes from outside our type-safe system. Once data is inside our typed flow, preserve and propagate types end-to-end instead of re-casting or widening. The **only** acceptable `as` casts are at clearly documented library boundaries where a third-party type is provably too strict, and each such cast must have an adjacent comment explaining why. For error inspection in `catch` blocks, use the `in` operator instead of casting `unknown` errors.
- **Good**: `if (typeof err === 'object' && err !== null && 'status' in err && typeof err.status === 'number') { … }`
- **Bad**: `const raw = err as Record<string, unknown>; const status = raw.status as number`

## 5. Middleware and Auth

Expand Down Expand Up @@ -147,6 +150,14 @@ For one-off auth in other handlers, use `requireAuth(context)` or `requireGroup(

Create composable middleware that chains `authMiddleware` for context typing (see `src/middleware/requireAuth.ts` for the pattern). Chain it with `invalidateMiddleware` on POST server functions.

### Public-Route Allowlist

`authMiddleware` maintains a `PUBLIC_ROUTES` set (health checks, `.well-known`, static asset paths, public config endpoints) for which it returns a synthetic anonymous `AuthContext` instead of throwing `401`. Anonymous users have `user` and `userProfile` set to explicit `Anonymous` sentinels so the context shape is always uniform — downstream code, typings, and AI tools never need to branch on "maybe no user". Keep the allowlist small and specific; every other path requires authentication.

### Pre-Auth Redirect Middleware (Legacy Path Compatibility)

When a URL scheme changes (renaming a route segment, collapsing multiple paths into one, moving from hash-based to path-based routing), register a **pre-auth** middleware that short-circuits the request with a `308 Permanent Redirect` **before** `authMiddleware` runs. This avoids spurious `401` responses on old links shared in emails, bookmarks, or external systems. Implement the redirect via `new Response(null, { status: 308, headers: { Location: newPath } })` using a **relative** `Location` so it works across dev, staging, and production without hard-coded origins. Keep the allowlist of legacy paths small and retire entries once upstream consumers have migrated.

## 6. Server Functions and Data Access

This is a full-stack TanStack Start application. There is no separate backend API. The [skill](.agents/skills/tanstack-fullstack-pattern/SKILL.md) defines the rigid layering rules and the "every repo method gets a tool" policy.
Expand Down Expand Up @@ -193,7 +204,10 @@ const result = await processResponse(() => myMutation({ data: input }))
- **File-Based Routing**: Routes are defined as files in `src/routes/`. The route tree is auto-generated.
- **Nested Routes**: Use nested routes for modals and lazy-loaded UI.
- **Loaders over useEffect**: Data fetching belongs in `loader`, not in component effects. See section 6.1.
- **Preserve Search Params**: `navigate({ to: '/path', search: prev => ({ ...prev, newParam: 'value' }) })`.
- **Preserve Search Params**: `navigate({ to: '/path', search: prev => ({ ...prev, newParam: 'value' }) })` — always use the functional form so the router diffs prior search params instead of requiring the caller to read them via `useSearch()` first. Reading `useSearch()` only to spread it into a `navigate` call re-subscribes the component to search changes and is the most common source of unnecessary re-renders.
- **`loaderDeps` for cache keying**: When a loader's output depends on `validateSearch` params (filters, pagination, search text), use `loaderDeps: ({ search }) => search` and pull from `deps` inside the loader. Do not read `useSearch()` inside the loader — only `deps` participates in the router's cache key. Use `loaderDeps` to pick *only* the search fields that affect the fetch; unrelated fields (e.g. an "expanded" flag that affects UI only) should be excluded so they do not invalidate the cache.
- **Link wrapper**: Prefer the project-local `Link` wrapper (see rule 32 in the skill) over raw `@tanstack/react-router` `Link` so internal navigation preserves search params by default.
- **Child routes read from parent loaders**: Deep components access already-loaded parent data via `getRouteApi('/parent-path').useLoaderData()` or `useLoaderData({ from: '/parent-path' })` rather than re-fetching. Loader-derived values (permissions, feature flags, the active user) flow into client hooks as stable snapshots — no `useEffect` + `useState` for data the loader already has.

## 8. AI Chat and Tools

Expand Down Expand Up @@ -237,6 +251,27 @@ The default implementation uses `@tanstack/ai-openai` with plain OpenAI. Set `OP
- When adding new repository methods (reads and writes), expose them as AI tools.
- **getCurrentUserContext** returns who is logged in and a permissions summary. **createTask**, **updateTask**, **deleteTask** call the same server functions as the UI; auth and creator checks run inside the server function handlers.

### Virtual-Field Explanation Registry

Some fields the AI surfaces are **computed**, not stored (e.g. a `status` derived from dates, a `health` score computed from a mix of columns, a `priority` inferred from SLA distance). The AI should not be asked to invent the rule each time. Instead, keep a single `fieldMetadata.ts` module that maps each virtual field to a short, stable explanation string, and expose an `explainField(fieldName)` AI tool plus a JSDoc-visible `VIRTUAL_FIELDS` constant consumed by the system prompt. When you add or change a derivation in the repository / mapping layer, update the registry in the same commit. This prevents the AI from answering "why is this Pending?" with a plausible-sounding guess that drifts from the actual rule.

### Fluent System Prompt Builder

For apps whose `buildSystemPrompt()` starts composing many conditional blocks (permissions, feature flags, location, time, navigation manifest, entity summaries), extract a tiny fluent builder in `src/services/ai/promptBuilder.ts`:

```tsx
// Pseudocode — keep the API small
const prompt = new SystemPromptBuilder()
.section('Capabilities', capabilitiesText)
.sectionIf(user.isAdmin, 'Admin-only tools', adminToolsText)
.section('Current User', renderCurrentUser(user))
.section('Browser Context', renderBrowserContext(ctx))
.section('Navigation', getNavigationPromptSection())
.build()
```

The builder's only responsibility is: ordered sections, conditional inclusion, consistent `## Heading` / blank-line framing, and a final `.build(): string`. It must not know domain details. Prefer this once a `buildSystemPrompt()` function exceeds ~5 appended strings.

### Client Tools

Client tools execute in the browser and are defined in `src/services/ai/tools.ts` (definition-only, no `.server()` call) with implementations in [ChatDrawer](src/components/ChatDrawer/ChatDrawer.tsx) using `clientTools()` from `@tanstack/ai-client`:
Expand Down Expand Up @@ -515,9 +550,94 @@ This project uses [Biome](https://biomejs.dev/) as the default linter and format
- **Always use latest versions**: When adding dependencies, run `pnpm add <pkg>` without a version suffix so the package manager resolves the newest release. Never pin exact versions unless there is a known incompatibility.
- **Keep dependencies up to date**: Run `pnpm outdated` to check for stale packages and `pnpm update` to align the lockfile with the latest compatible versions within current ranges.
- **Major version upgrades are conscious decisions**: When `pnpm outdated` shows a major version bump, upgrade explicitly with `pnpm add <pkg>@latest`, then verify with `pnpm lint && pnpm test && pnpm build` before committing.
- **After any dependency change**, run the full validation checklist (section 13) to catch regressions.
- **After any dependency change**, run the full validation checklist (section 17) to catch regressions.

## 13. Public Runtime Config

Some config values differ per deployment (Sentry DSN, environment name, feature flags) but are **not secrets** and the client needs them immediately. Do not bake them into the Vite bundle via `import.meta.env` — that ties the built artifact to one environment. Instead, expose them through a GET server function and inline the result into the HTML document as `window.__ENV__` so the client can read them synchronously before any module runs.

### Server function

```tsx
// src/services/api/serverFns.ts
export const getPublicEnv = createServerFn({ method: 'GET' }).handler(async () => {
return {
sentryDsn: process.env.VITE_SENTRY_DSN ?? null,
environment: process.env.ENV ?? 'development',
featureFlags: {
/* flags that are safe to expose */
},
}
})
```

### Inline into the document

Call `getPublicEnv()` in the root loader and inject the result via a `<script>` tag in `RootDocument` (`src/routes/__root.tsx`). Use `JSON.stringify(...).replace(/</g, '\\u003c')` so the payload cannot break out of the `<script>` tag:

```tsx
const envJson = JSON.stringify(publicEnv).replace(/</g, '\\u003c')
// In RootDocument <head>:
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: seeds window.__ENV__ before client JS runs
dangerouslySetInnerHTML={{ __html: `window.__ENV__ = ${envJson};` }}
/>
```

### Declare the global type

```ts
// src/vite-env.d.ts
declare global {
interface Window {
__ENV__: {
sentryDsn: string | null
environment: string
featureFlags: Record<string, boolean>
}
}
}
export {}
```

Client-side SDK initialization (Sentry, analytics, etc.) reads `window.__ENV__` at the top of its init module. Because the script tag is emitted before client JS, reads are always synchronous and well-typed.

## 14. Bulk Edit Pattern

For lists where users need to change many rows at once (multi-row updates of status, priority, ownership), use a dedicated `/entity/edit?selected=id1&selected=id2` route driven entirely by URL state:

1. **Selection** lives in the parent list's URL search param as a repeated key (`selected=...`). A small `useMultiEditSelection()` hook reads it via `useSearch()` and provides `toggle`, `clear`, `isSelected`.
2. **Edit route** validates `selected: z.array(z.string()).default([])` in `validateSearch` and fetches those rows in a single loader call.
3. **Category tabs** group editable fields (e.g. "Base", "Ownership", "Details") via a Mantine `SegmentedControl` backed by another URL search param so switching categories never loses progress.
4. **Dirty tracking** keeps a cross-tab `Map<rowId, Partial<Fields>>` so only changed cells are included in the save payload.
5. **An "Apply all" row** lets the user set a single value for a column across every selected row; clicking it writes that value into every row's dirty map.
6. **Batch server function** uses MongoDB `bulkWrite` (or the equivalent) for a single round-trip, validates permissions per row, and chains `invalidateMiddleware` so the list refetches on success.
7. **Close/cancel** clears the `selected` search param on the parent list so stale selections don't leak across navigations.

Keep the multi-edit form layout agnostic of entity specifics by defining column metadata (label, editor type, permission check, read-only flag) in a single table so new fields plug in without touching the form component.

## 15. Override / Overlay Repository Pattern

Some entities are sourced from upstream systems (a data warehouse export, a Google Sheet, a vendor API) but users need to annotate them with app-local fields (notes, custom categorization, manual corrections). The clean way to model this is an **overrides overlay**:

- **Source collection** (read-only, refreshed by a pipeline) holds the upstream rows.
- **Overrides collection** holds sparse per-entity documents with only the user-edited fields plus audit metadata (`lastModifiedBy`, `lastModifiedDate`).
- **Read path**: repository loads source rows and overrides in parallel, then runs a **pure `applyOverrides(source, override)`** function that shallow-merges override values over source values, treating `null` as "revert to source". The merged view is what the UI and AI see.
- **Write path**: mutations target the overrides collection with `$set` for provided fields and `$unset` for explicit `null`s, using `upsert: true` via `bulkWrite` so concurrent edits are resilient.
- **UI affordance**: the form renders the current (merged) value and shows a subtle "Revert to source" affordance next to overridden fields. Clicking it sets the field to `null` on the next save, which `$unset`s it and restores the source value on next read.

Keep the overlay function pure and unit-tested — it is the single place where "what does the user see" is defined.

## 16. Prefer Controlled Components

Prefer controlled components in general. Keep data and state orchestration at the route level, and keep UI components focused on rendering and events.

- **Loaders fetch data, components receive props**: Route loaders own data fetching and pass data down to page/components. Avoid embedding read-fetch lifecycle logic in reusable UI components.
- **State lives in route search params**: Filters, sorting, pagination, active tabs, and modal state belong in validated route search params so URLs remain shareable and restorable.
- **Controlled inputs are easier to test**: Components that receive value/state via props and emit callbacks (`value` + `onChange`) are simpler to unit test, easier to reuse, and easier to reason about than components with hidden internal state.
- **Use uncontrolled only with clear justification**: Keep uncontrolled state for isolated UX details that are intentionally local and not part of route/application state.

## 13. Validate Changes
## 17. Validate Changes

Always verify changes with:

Expand Down
Loading
Loading