Frontend — React / TypeScript / Vite
中文版: frontend.zh.md
Coffer's frontend is the desktop app's web surface (frontend/), rendered in the Tauri shell in production and the Vite dev server (make dev) in development. This file is the engineering convention for that surface — the counterpart to stack.md (backend) and visual-language.md (visual design). Read it before touching frontend/src.
If this file and the code disagree, the code that matches the canonical column below wins; fix the outlier. If a rule here blocks you, stop and raise it — do not invent a parallel pattern.
1. Stack
- React 18 + TypeScript 5 (strict), Vite build, React Router v6.
- TanStack Query v5 for all server state. No Redux / Zustand / MobX.
- openapi-fetch + openapi-typescript for the typed API client where the spec ships an OpenAPI contract; a shared hand-written helper otherwise (§4).
- shadcn/ui + Radix + Tailwind for the design system (§6).
- react-hook-form + zod for forms, i18next for copy, lucide-react for icons.
Feature-specific libraries are added by the spec that first needs them (e.g. a markdown renderer), not pre-installed.
2. Folder Structure (canonical)
One scheme. A feature X lives in exactly these places:
src/pages/XPage.tsx — list/index page; XDetailPage.tsx for detail
src/components/x/ — feature components (PascalCase files)
src/lib/hooks/useX.ts — ALL queries + mutations for X (see §3)
src/lib/api/x.ts — wire types + request functions for /api/v1/x
src/i18n/locales/{en,zh}.json — under the top-level "x" key- Data fetching lives in a hook file, never inline in a component. A page or component calls
useX(); it does not calluseQuery/useMutationdirectly. (Legacy debt:src/kinds/knowledge_baseandsrc/kinds/memoryinline their queries in the detail page — that is the pattern to migrate AWAY from, not to copy.) src/kinds/<name>/is for the kind-registry UI modules only (theKindUIModulethat the resource framework auto-renders). New plain features use thepages+components+hooks+apilayout above, not akinds/<name>/module.- UI primitives live in
src/components/ui/(shadcn). Cross-feature helpers go insrc/lib/.
3. State Management
| State kind | Where it lives |
|---|---|
| Server data (anything from the daemon) | TanStack Query, via a useX hook |
| Ephemeral UI state (open/collapsed, draft input) | local useState in the component |
| User preference that must survive reload | localStorage via src/lib/preferences.ts / auth.ts |
| Addressable app state (which conversation/resource is open) | the URL (router param), not useState |
The last row matters: anything a user would expect to survive a refresh, deep-link, or back-button MUST be a route param (/chat/:id, /agents/:name), not local state. "Which item is selected" is navigation, not UI state.
There is no global store. Cross-component server data is shared through the query cache (same query key → same data), not through Context. The only Context providers are QueryClientProvider and ToastProvider.
Query keys
Hierarchical arrays, first segment = the feature noun. A detail/sub-resource extends the parent key so a prefix invalidation catches the whole subtree:
["agents"] // list
["agents", name] // one agent
["agents", name, "config-files"] // a sub-resource of that agent- Do NOT use flat hyphenated keys (
["kb-documents", name]) — they cannot be invalidated as a group. Memory/KB currently do this; new code must not. - Export the key builders from the hook file (
conversationKey(id),messagesKey(id)), don't inline string arrays at call sites.
4. API Layer
Every request module under src/lib/api/ resolves base URL + token through src/lib/auth.ts (getCofferBaseUrl, getCofferToken) and sends X-Coffer-Token + X-Coffer-Actor: "ui". The actor is always "ui" from the web surface (the "user" in kinds/memory/api.ts is a known outlier).
Two request styles exist; pick by whether the spec shipped an OpenAPI contract:
- Generated client (
getApiClient()oversrc/lib/api/client.ts+ the codegen'dtypes.ts) — preferred when the surface is in the OpenAPI spec. Full path/response type safety. - Shared hand-written helper otherwise. Target state: one shared
call<T>()(URL building + 204 handling +{error:{code,message}}→ApiError). Todaycall<T>()is copy-pasted inapi/chat.ts,agents.ts,models.ts,skills.ts— when you touch one, lift it to a sharedsrc/lib/api/call.tsand have the module import it. Do not add a fifth copy.
All errors converge on ApiError(code, message) (src/lib/api/errors.ts). Surface them with translateApiError(t, error), which maps errors.<CODE> i18n keys with the server message as fallback. Never show a raw error string.
Streaming (chat SSE) is the one path outside TanStack Query: a typed async-generator in src/lib/chat/streamClient.ts. Keep wire-event parsing there; accumulate into view state in a hook (useChatTurn), not in components.
5. Mutations & Cache Invalidation
Default pattern — invalidate on success, toast on error:
return useMutation({
mutationFn: (vars) => xApi.update(vars),
onSuccess: () => void qc.invalidateQueries({ queryKey: ["x"] }),
onError: (e) => toast.error(translateApiError(t, e)),
});onError→ toast is the default, not optional. (Several existing hooks omit it — that is a gap, not a precedent.)- Optimistic
setQueryDataonly where the latency is user-visible and the shape is trivially patchable (e.g. rename, model switch). Always invalidate after, so the server stays authoritative. - Delete removes the detail + sub keys (
removeQueries) then invalidates the list, so a stale detail view can't refetch a 404. - Bulk/table actions go through
src/lib/hooks/useBulkMutate.ts(one summary toast + one invalidation burst), never a per-row toast loop. - Prefix
qc.invalidateQueries(...)calls withvoid(they're fire-and-forget).
6. Components & Design System
- Build from
src/components/ui/primitives (shadcn:Button,Dialog,Select,Textarea, …). Don't hand-roll a control a primitive already covers. - Named exports only.
export function Foo(). No default exports. - File header comment: first line
// src/path+ one line of purpose. cn()(src/lib/utils) for conditional classes. No inlinestyleexcept a documented theming bridge.- Semantic tokens only (
text-muted-foreground,bg-card,border-border). Health/status surfaces use thestatus.ok|warn|errtokens — not rawgreen/amber/emeraldpalette classes. (statusColors.ts,ToolCallCardcurrently bypass this — fix on touch.) - Type scale only —
text-sm/text-xs/… nevertext-[11px]. Radius from the prescribed set (rounded-lgcards,rounded-mdcontrols,rounded-smchips). Seevisual-language.md. - Light-only. Do not add
dark:variants — there is no dark token set; they are dead styles. - Keep files focused; a component growing past a few hundred lines is a signal to split. One component per file, test colocated (§8).
7. TypeScript & i18n
- strict +
noUnusedLocals/Parameters. Zeroanyinsrc(not lint- enforced — discipline). Useunknown+ narrowing instead. interfacefor props/object shapes,typefor unions/aliases. Type-only imports useimport type.- Props: a local
interface Props { … }destructured in the signature; JSDoc the non-obvious ones. - Every user-facing string goes through
t(...). No hardcoded copy, no hardcoded aria-labels. - Keys are nested camelCase dotted paths under a feature namespace (
chat.composer.placeholder). en and zh stay at exact key parity — add to both in the same change;src/i18n/locales.test.tsguards it.
8. Testing
- Vitest + Testing Library,
*.test.tsxcolocated next to the unit. - Test behaviour through the component/hook, not implementation. Render with a real
QueryClientProvider; mock only the network boundary (theapimodule orstreamClient). - A new component or hook ships with its test in the same commit. Acceptance- scenario coverage follows
testing.mdmarkers. - Run
cd frontend && npx vitest run <file>for a focused check;make verifyruns the suite + lint + tsc.
9. Convergence Backlog (known debt → target state)
When you work near these, migrate toward the target; don't extend the debt:
- One
call<T>()insrc/lib/api/call.ts; the four hand-written API modules import it instead of each owning a copy. - Hook-file data fetching everywhere — extract the inline
useQuery/useMutationout ofkinds/knowledge_baseandkinds/memorydetail pages intouseKnowledgeBase/useMemoryStorehooks. - Hierarchical query keys everywhere —
["memory", …]/["kb", …]replacing the flat["memory-facts", …]/["kb-documents", …]keys. - Actor header
"ui"everywhere — fixkinds/memory/api.ts. onErrortoast on every mutation that can fail visibly.