diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d698c60b230..c3c98efe894 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,7 +92,7 @@ directories until the move is complete. | Directory | Status | |---|---| -| `apps/web/` | Active migration target — Vite + React Router v7 SPA for the assistant web app. Code is being incrementally migrated from a separate repo. See [`apps/web/README.md`](apps/web/README.md) for local dev setup, [`apps/web/CONVENTIONS.md`](apps/web/CONVENTIONS.md) for architecture and state management patterns, and [`apps/web/STYLE_GUIDE.md`](apps/web/STYLE_GUIDE.md) for coding style. | +| `apps/web/` | Active migration target — Vite + React Router v7 SPA for the assistant web app. Code is being incrementally migrated from a separate repo. See [`apps/web/README.md`](apps/web/README.md) for local dev setup, [`apps/web/docs/CONVENTIONS.md`](apps/web/docs/CONVENTIONS.md) and [`apps/web/docs/STATE_MANAGEMENT.md`](apps/web/docs/STATE_MANAGEMENT.md) for architecture and state, and [`apps/web/docs/STYLE_GUIDE.md`](apps/web/docs/STYLE_GUIDE.md) for coding style. | | `apps/chrome-extension/` | Move in progress from [`clients/chrome-extension/`](https://github.com/vellum-ai/vellum-assistant/tree/main/clients/chrome-extension). | ## Submitting a pull request diff --git a/apps/ios/README.md b/apps/ios/README.md index d4bba5992c0..835af9d5117 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -95,7 +95,7 @@ Apple's reference for the toolbar controls: > **Web-side conventions for iOS code paths**: any change to the web app > that might run inside this WKWebView shell needs to follow the patterns -> in [`apps/web/CAPACITOR.md`](../web/CAPACITOR.md) — Capacitor plugin +> in [`apps/web/docs/CAPACITOR.md`](../web/docs/CAPACITOR.md) — Capacitor plugin > lazy imports, native auth, deep links, autogrowing textareas, > streaming watchdogs, OS permission UI, etc. diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 80fba43047d..1738dc1cdb5 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -1,20 +1,26 @@ # Web App — Agent Instructions -Applies to all code under `apps/web/`. Subordinate to [`apps/AGENTS.md`](../AGENTS.md) and root [`AGENTS.md`](../../AGENTS.md). +Applies to all code under `apps/web/`. For broader patterns see [`apps/AGENTS.md`](../AGENTS.md) and root [`AGENTS.md`](../../AGENTS.md). ## Conventions and style Read these before making changes: -- **[`CONVENTIONS.md`](./CONVENTIONS.md)** — Architecture, code organization, state management, component patterns, framework strategy, data fetching, testing. -- **[`STYLE_GUIDE.md`](./STYLE_GUIDE.md)** — Naming, imports, TypeScript, component authoring, formatting. -- **[`CAPACITOR.md`](./CAPACITOR.md)** — Capacitor / iOS patterns: lazy plugin imports, native auth, deep links, autogrowing textareas, streaming watchdogs, OS permission UI, capability detection, keyboard-only affordances. Mandatory reading if any code path you're touching might run inside the iOS WKWebView shell. +- **[`docs/CONVENTIONS.md`](./docs/CONVENTIONS.md)** — Architecture, code organization, component patterns, framework strategy, data fetching, testing. +- **[`docs/STATE_MANAGEMENT.md`](./docs/STATE_MANAGEMENT.md)** — Zustand stores, atomic selectors, TanStack Query, the no-`useReducer` rule. +- **[`docs/STYLE_GUIDE.md`](./docs/STYLE_GUIDE.md)** — Naming, imports, TypeScript, component authoring, formatting. +- **[`docs/CAPACITOR.md`](./docs/CAPACITOR.md)** — Capacitor / iOS patterns: lazy plugin imports, native auth, deep links, autogrowing textareas, streaming watchdogs, OS permission UI, capability detection, keyboard-only affordances. Mandatory reading if any code path you're touching might run inside the iOS WKWebView shell. + +When a topic in `docs/CONVENTIONS.md` grows past ~100 lines and has a +coherent boundary, extract it into a `docs/TOPIC.md` sibling with a +short pointer back from `CONVENTIONS.md`. Matches the repo's existing +pattern (`assistant/docs/`, `docs/` at the repo root). ## Stack - **Build**: [Vite](https://vite.dev/) + [React 19](https://react.dev/blog/2024/12/05/react-19) - **Routing**: [React Router v7](https://reactrouter.com/) — [data mode](https://reactrouter.com/start/modes) (`createBrowserRouter`), NOT framework mode -- **Client state**: [Zustand](https://zustand.docs.pmnd.rs/) — all shared state uses Zustand stores (see [CONVENTIONS.md — State management](./CONVENTIONS.md#state-management)) +- **Client state**: [Zustand](https://zustand.docs.pmnd.rs/) — all shared state uses Zustand stores (see [`docs/STATE_MANAGEMENT.md`](./docs/STATE_MANAGEMENT.md)) - **Server state**: [TanStack Query](https://tanstack.com/query/latest) with [HeyAPI plugin](https://heyapi.dev/openapi-ts/plugins/tanstack-query) - **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) via `@tailwindcss/vite` - **Design system**: `@vellum/design-library` at [`packages/design-library/`](../../packages/design-library/) @@ -25,8 +31,8 @@ Read these before making changes: - Route config: `src/routes.tsx` - Route constants: `src/utils/routes.ts` — all paths are absolute browser paths - No `basename` on the router — `/account/*` and `/assistant/*` are explicit top-level branches -- Routes must match the platform repo exactly during migration (no URL changes) -- **Route protection**: uses React Router v7 [middleware](https://reactrouter.com/how-to/middleware) (`v8_middleware` future flag), not layout gate components or `useEffect` redirects. Auth is always required — the middleware redirects unauthenticated users to `/account/login`. See [CONVENTIONS.md — Route protection via middleware](./CONVENTIONS.md#route-protection-via-middleware). +- URL paths are part of the contract — bookmarks and deep links depend on them. Don't rename URL patterns without a deprecation period. +- **Route protection**: uses React Router v7 [middleware](https://reactrouter.com/how-to/middleware) (`v8_middleware` future flag), not layout gate components or `useEffect` redirects. Auth is always required — the middleware redirects unauthenticated users to `/account/login`. See [`docs/CONVENTIONS.md` — Route protection via middleware](./docs/CONVENTIONS.md#route-protection-via-middleware). - **Assistant lifecycle**: owned by `ChatLayout`, passed to child routes via [outlet context](https://reactrouter.com/start/framework/outlet). Child routes consume the resolved `assistantId` via `useAssistantContext()` — never hardcode or independently resolve it. ## Commands @@ -42,10 +48,6 @@ cd apps/web && bun test src/path/to/file.test.ts # Run specific tests cd apps/web && bun run test:ci # Run all tests (isolated, CI) ``` -## Migration status - -This app is being migrated from [`vellum-assistant-platform/web/`](https://github.com/vellum-ai/vellum-assistant-platform). During migration: +## Scope -- **Faithful copy, not simplification.** Port real implementations, not stubs. All Capacitor/native code paths must be preserved. -- **Convention compliance on arrival.** Apply this repo's naming (kebab-case), import conventions (`.js` extensions, `@/` aliases), and directory structure as code is ported. -- **No marketing or admin pages.** Only the assistant web app and auth/identity pages are migrating. +This package contains only the assistant web app and authentication / identity pages. Marketing pages and admin/internal surfaces are out of scope. diff --git a/apps/web/README.md b/apps/web/README.md index cdaae1284c1..7b1d93a8934 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -12,7 +12,7 @@ Vellum assistant web app (chat, settings, library, docs). ``). - [Zustand](https://zustand.docs.pmnd.rs/) for shared client state (messages, streaming, interactions, conversations). See - [`CONVENTIONS.md`](./CONVENTIONS.md#state-management) for store patterns. + [`docs/STATE_MANAGEMENT.md`](./docs/STATE_MANAGEMENT.md) for store patterns. - [TanStack React Query](https://tanstack.com/query/latest) for server state (API calls, caching, mutations). - [HeyAPI](https://heyapi.dev/) for OpenAPI client generation with @@ -104,11 +104,12 @@ deterministic results are required. ## Architecture -See [`CONVENTIONS.md`](./CONVENTIONS.md) for code organization -(domain-based architecture), state management patterns (Zustand + -React Query), component conventions, and framework strategy. +See [`docs/CONVENTIONS.md`](./docs/CONVENTIONS.md) for code organization +(domain-based architecture), component conventions, and framework strategy. +See [`docs/STATE_MANAGEMENT.md`](./docs/STATE_MANAGEMENT.md) for state +patterns (Zustand + TanStack Query). -See [`STYLE_GUIDE.md`](./STYLE_GUIDE.md) for naming, imports, +See [`docs/STYLE_GUIDE.md`](./docs/STYLE_GUIDE.md) for naming, imports, TypeScript rules, and formatting. ## Directory structure diff --git a/apps/web/CAPACITOR.md b/apps/web/docs/CAPACITOR.md similarity index 90% rename from apps/web/CAPACITOR.md rename to apps/web/docs/CAPACITOR.md index c2e551d774b..5c4bf9156c4 100644 --- a/apps/web/CAPACITOR.md +++ b/apps/web/docs/CAPACITOR.md @@ -2,9 +2,9 @@ The web app ships as both a browser SPA and the JS layer of a [Capacitor](https://capacitorjs.com/) iOS shell that loads it in a `WKWebView`. The patterns below are mandatory for any code path that might run inside Capacitor iOS — most of them address real iOS-specific failure modes that desktop browsers silently tolerate. -If you're touching anything in `apps/web/src/runtime/`, anything that calls a `@capacitor/*` plugin, anything that streams from the daemon, anything that auto-resizes based on content, or anything that gates a browser API that triggers an OS permission alert — start here. +> **Read this only if your change touches iOS code paths.** For browser-only contributions you can skip this document. Building the iOS app itself additionally requires macOS and Xcode; the native shell lives in [`apps/ios/`](../../../apps/ios/). -The native iOS shell that consumes these patterns lives at [`apps/ios/`](../../apps/ios/). +If you're touching anything in `apps/web/src/runtime/`, anything that calls a `@capacitor/*` plugin, anything that streams from the daemon, anything that auto-resizes based on content, or anything that gates a browser API that triggers an OS permission alert — start here. --- @@ -39,13 +39,13 @@ References: ## Native auth on iOS -Native auth uses [`ASWebAuthenticationSession`](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) (Safari sheet) via a `NativeAuth` Capacitor plugin — see [`src/runtime/native-auth.ts`](./src/runtime/native-auth.ts) and the Swift side at [`apps/ios/App/App/NativeAuthPlugin.swift`](../../apps/ios/App/App/NativeAuthPlugin.swift). +Native auth uses [`ASWebAuthenticationSession`](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) (Safari sheet) via a `NativeAuth` Capacitor plugin — see [`src/runtime/native-auth.ts`](../src/runtime/native-auth.ts) and the Swift side at [`apps/ios/App/App/NativeAuthPlugin.swift`](../../../apps/ios/App/App/NativeAuthPlugin.swift). -- **Protected (app) routes**: route protection middleware (see [`CONVENTIONS.md` § Route protection via middleware](./CONVENTIONS.md#route-protection-via-middleware)) redirects unauthenticated users to `/account/login?returnTo=…`. Individual pages should **not** render inline sign-in gates. Return `null` when `!isLoggedIn` and let the middleware handle the redirect. The branded login page (`/account/login`) renders a native login form (inside [`NativeSplash`](./src/components/native-splash.tsx)) on Capacitor iOS and a web login form on web. +- **Protected (app) routes**: route protection middleware (see [`CONVENTIONS.md` § Route protection via middleware](./CONVENTIONS.md#route-protection-via-middleware)) redirects unauthenticated users to `/account/login?returnTo=…`. Individual pages should **not** render inline sign-in gates. Return `null` when `!isLoggedIn` and let the middleware handle the redirect. The branded login page (`/account/login`) renders a native login form (inside [`NativeSplash`](../src/components/native-splash.tsx)) on Capacitor iOS and a web login form on web. - **iOS login — no `providerHint`**: the iOS login form must use a single "Sign in" button with **no `providerHint`**. Do NOT add individual provider buttons or pass `providerHint` from the iOS login screen — see [Apple App Store Review Guideline 4](https://developer.apple.com/app-store/review/guidelines/#design) and [Guideline 4.8 — Sign in with Apple](https://developer.apple.com/app-store/review/guidelines/#login-services). The `providerHint` / `loginHint` parameters remain in the helper API for web and other use cases but must not be used from the iOS login entry point. - **Pre-fill identity-derived inputs from the auth claim**: when the platform / IdP returns identity claims on signup (Apple SIWA `given_name`/`family_name`, Google `given_name`/`family_name`, etc.), pre-fill any user-facing input that asks for that identity (e.g. "Your name") from the claim instead of forcing the user to retype it — [Apple Guideline 4](https://developer.apple.com/app-store/review/guidelines/#design) and [Apple HIG: Sign in with Apple](https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple) treat asking again as a violation. The field stays editable so users can pick a preferred nickname. - **Sign-in actions outside the app shell**: wrap sign-in links in a shared component that renders a native `startAuthFlow()` button on Capacitor iOS and a router `` on web — never a plain ``, which on iOS would navigate the WKWebView away from the running SPA. -- **Platform detection in JSX**: use a `useIsNativePlatform()` hook (which returns `false` during the first paint and settles to the real value on mount) — not the bare `isNativePlatform()` function — to avoid render flicker and hydration mismatches in any SSR/prerender path. If the hook doesn't exist yet at the call site, add it next to [`src/runtime/native-auth.ts`](./src/runtime/native-auth.ts). +- **Platform detection in JSX**: use a `useIsNativePlatform()` hook (which returns `false` during the first paint and settles to the real value on mount) — not the bare `isNativePlatform()` function — to avoid render flicker and hydration mismatches in any SSR/prerender path. If the hook doesn't exist yet at the call site, add it next to [`src/runtime/native-auth.ts`](../src/runtime/native-auth.ts). ### Platform short-circuits in capability detection @@ -64,7 +64,7 @@ Apple's [HIG — Requesting permission](https://developer.apple.com/design/human ### Keyboard-only affordances on touch devices -When the *only* way to act on a UI element is a hardware-keyboard gesture (e.g. `Tab` to accept an inline suggestion, `Cmd+Enter` to submit), gate its rendering on `!isPointerCoarse()` from [`@/utils/pointer`](./src/utils/pointer.ts). Touch soft keyboards on iOS and Android do not expose `Tab` or most modifier-key combinations, so the affordance is non-actionable on coarse-pointer devices and may also overflow narrow viewports if its layout depends on a paired keypress. To support touch as well, add a tap-equivalent (button, gesture) instead of suppressing. +When the *only* way to act on a UI element is a hardware-keyboard gesture (e.g. `Tab` to accept an inline suggestion, `Cmd+Enter` to submit), gate its rendering on `!isPointerCoarse()` from [`@/utils/pointer`](../src/utils/pointer.ts). Touch soft keyboards on iOS and Android do not expose `Tab` or most modifier-key combinations, so the affordance is non-actionable on coarse-pointer devices and may also overflow narrow viewports if its layout depends on a paired keypress. To support touch as well, add a tap-equivalent (button, gesture) instead of suppressing. Reference: [MDN: `(pointer)` media feature](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/pointer). @@ -72,7 +72,7 @@ Reference: [MDN: `(pointer)` media feature](https://developer.mozilla.org/en-US/ ## Deep links (Capacitor `appUrlOpen`) -Native OAuth completion auto-dismisses `SFSafariViewController` by redirecting to a registered custom URL scheme (`vellum-assistant://`, `-dev`, `-staging`) and routing the URL via the `@capacitor/app` plugin's `appUrlOpen` listener. The router is mounted globally for the app routes; pure utilities and the typed `WindowEventMap` augmentation live in [`src/runtime/native-deep-link.ts`](./src/runtime/native-deep-link.ts). +Native OAuth completion auto-dismisses `SFSafariViewController` by redirecting to a registered custom URL scheme (`vellum-assistant://`, `-dev`, `-staging`) and routing the URL via the `@capacitor/app` plugin's `appUrlOpen` listener. The router is mounted globally for the app routes; pure utilities and the typed `WindowEventMap` augmentation live in [`src/runtime/native-deep-link.ts`](../src/runtime/native-deep-link.ts). - **Build deep links via `buildOAuthCompleteDeepLink()`.** Don't hand-construct URLs — the helper picks the right scheme per host (`getNativeUrlSchemeForHost`) and encodes the payload consistently. - **Parse via `parseOAuthCompleteDeepLink()`.** It exact-matches the scheme against the apex allow-list, rejects look-alikes (e.g. `vellum-assistant-evil://`), requires the `oauth-complete` host, and enforces a non-empty `requestId`. Adding a new scheme means adding it to the allow-list — do not loosen the matcher to a `startsWith` check. @@ -117,6 +117,7 @@ References: ## See also -- [`CONVENTIONS.md`](./CONVENTIONS.md) — architecture, code organization, state management, component patterns. +- [`CONVENTIONS.md`](./CONVENTIONS.md) — architecture, code organization, component patterns. +- [`STATE_MANAGEMENT.md`](./STATE_MANAGEMENT.md) — Zustand stores, atomic selectors, TanStack Query. - [`STYLE_GUIDE.md`](./STYLE_GUIDE.md) — naming, imports, TypeScript, component authoring. -- [`apps/ios/README.md`](../../apps/ios/README.md) — Capacitor iOS shell setup, Xcode targets, release pipeline. +- [`apps/ios/README.md`](../../../apps/ios/README.md) — Capacitor iOS shell setup, Xcode targets, release pipeline. diff --git a/apps/web/CONVENTIONS.md b/apps/web/docs/CONVENTIONS.md similarity index 58% rename from apps/web/CONVENTIONS.md rename to apps/web/docs/CONVENTIONS.md index af458f0a564..fbbee8fafbd 100644 --- a/apps/web/CONVENTIONS.md +++ b/apps/web/docs/CONVENTIONS.md @@ -5,8 +5,7 @@ Covers code organization, state management, component design, and framework strategy. For coding style, naming, and import rules see [`STYLE_GUIDE.md`](./STYLE_GUIDE.md). -Subordinate to [`apps/AGENTS.md`](../AGENTS.md) and root -[`AGENTS.md`](../../AGENTS.md). +See also [`apps/web/AGENTS.md`](../AGENTS.md) for the quick-rules entry point, and broader patterns in [`apps/AGENTS.md`](../../AGENTS.md) / root [`AGENTS.md`](../../../AGENTS.md). --- @@ -15,7 +14,7 @@ Subordinate to [`apps/AGENTS.md`](../AGENTS.md) and root The web app is a **Vite + React Router v7 SPA** using [library / data-router mode](https://reactrouter.com/start/modes) (`createBrowserRouter` + ``). See -[`apps/web/README.md`](./README.md) for the full stack description and +[`apps/web/README.md`](../README.md) for the full stack description and local development commands. ### Why Data mode, not Framework mode @@ -131,7 +130,7 @@ src/ #### Why `domains/` not `features/` -The team chose `domains/` over the more common `features/` because +This app uses `domains/` over the more common `features/` because "features" implies product-level concepts (like "chat" or "settings") that contain multiple domains. `messages`, `conversations`, and `streaming` are business domains with distinct @@ -258,399 +257,23 @@ If the code imports a third-party SDK and configures it → `lib/`. If it bridge ### No barrel files Do not use barrel files (`index.ts` that re-export siblings). Import -from the source file directly. If a genuine need arises in the future, -discuss with the team before adding one. +from the source file directly. If you believe this rule should change, +open a GitHub issue to discuss. --- ## State management -### Zustand for shared mutable state +State management has its own document: see +[`STATE_MANAGEMENT.md`](./STATE_MANAGEMENT.md). -Use [Zustand](https://github.com/pmndrs/zustand) for state shared -across multiple components — messages, turn state, interactions, -conversation list, viewer state. Zustand was chosen over Context + -useReducer because: +Quick summary: -- **Selector support.** `useStore(selector)` lets each component - subscribe to only the slice it needs. Context has no selector - support — every consumer re-renders on any change, which is - unacceptable during streaming (messages update every ~50ms). -- **Framework-agnostic store definitions.** Store logic is plain - TypeScript with no React dependency — portable across environments. -- **Direct named actions.** Store actions are plain functions that - call `set()` — no dispatchers, no action types, no switch statements. - See [Zustand store conventions](#zustand-store-conventions). +- **Client state** lives in Zustand stores with direct named actions and atomic selectors via `createSelectors`. +- **Server state** lives in TanStack Query. +- **`useReducer` is not used** for client state, even within a single hook. See [STATE_MANAGEMENT.md — useReducer is not used](./STATE_MANAGEMENT.md#usereducer-is-not-used-for-client-state). +- **`useShallow`** is not introduced in new code — atomic selectors avoid the need. -```ts -// Good — component only re-renders when its slice changes -const messages = useChatStore((s) => s.messages); - -// Avoid — every consumer re-renders on any context change -const { messages } = useContext(ChatContext); -``` - -References: -- [Zustand docs](https://zustand.docs.pmnd.rs/) -- [Zustand — Auto-generating selectors](https://zustand.docs.pmnd.rs/guides/auto-generating-selectors) - -### Zustand store conventions - -Each domain owns its store, colocated within the domain folder: -`domains/messages/message-store.ts`. Store files use -`{domain}-store.ts`. Zustand stores are module-level singletons with -both React hook and non-React APIs (`.getState()`, `.setState()`, -`.subscribe()`), so the file describes what the module *is* (a store), -while the exported hook uses the `use` prefix per React's -[Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks). - -References: -- [Zustand — TypeScript guide](https://zustand.docs.pmnd.rs/guides/typescript) -- [Bulletproof React — project structure](https://github.com/alan2207/bulletproof-react/blob/master/docs/project-structure.md) - -Store creation pattern — separate `State` and `Actions` interfaces, -wrap with `createSelectors` for auto-generated per-field hooks: - -```ts -import { create } from "zustand"; - -import { createSelectors } from "@/utils/create-selectors.js"; -import type { Message } from "./types.js"; - -// State — the data -export interface MessageState { - messages: Message[]; - activeThreadId: string | null; -} - -// Actions — direct named functions (no dispatch/reducer) -export interface MessageActions { - addMessage: (message: Message) => void; - setActiveThread: (threadId: string | null) => void; - clearMessages: () => void; -} - -// Combined store type -export type MessageStore = MessageState & MessageActions; - -const useMessageStoreBase = create()((set) => ({ - messages: [], - activeThreadId: null, - addMessage: (message) => - set((s) => ({ messages: [...s.messages, message] })), - setActiveThread: (threadId) => - set({ activeThreadId: threadId }), - clearMessages: () => - set({ messages: [], activeThreadId: null }), -})); - -export const useMessageStore = createSelectors(useMessageStoreBase); -``` - -Consumers use `.use.field()` in render bodies and `.getState()` in -callbacks — see -[Reading state: `.use.*` vs `.getState()`](#reading-state-use-vs-getstate). - -Keep domain-specific store definitions in their domain folder — -adding or removing a domain means adding or removing a folder. -Cross-domain stores (consumed by two or more domains, or with no -single domain owner — auth, viewer, feature flags, assistant -identity) live in top-level `stores/`. See the -[Decision rule for hooks/stores/utils](#top-level-shared-directories). - -References: -- [Zustand — TypeScript guide](https://zustand.docs.pmnd.rs/guides/typescript) -- [Zustand — Auto Generating Selectors](https://zustand.docs.pmnd.rs/learn/guides/auto-generating-selectors) - -### Auth state lives in a Zustand store - -Auth is cross-domain shared state — used by middleware, route -components, API interceptors, and platform bridges. It lives in a -Zustand store (`stores/auth-store.ts`), not a React Context. This -is critical because: - -- **Middleware and loaders** need auth state outside the React tree — - `useAuthStore.getState()` works anywhere; Context requires a - component. -- **API interceptors** need to read/write auth state synchronously. -- **Selector support** — components subscribe to only the auth slice - they need (e.g., `useAuthStore(s => s.isAuthenticated)`). - -References: -- [Zustand — Reading/writing state outside components](https://zustand.docs.pmnd.rs/guides/reading-and-writing-state-outside-components) -- [React Router — Middleware](https://reactrouter.com/how-to/middleware) - -### Turn state lives in `domains/messaging/turn-store.ts` - -Turn lifecycle (sending, thinking, streaming, idle, errored), queue -depth, active tool-call count, and current turn identity are managed -by the turn store. Use `useTurnStore(selector)` in React components -and `useTurnStore.getState()` in non-React code (stream handlers, -reconciliation). Do not prop-drill turn state or dispatch functions. - -Action naming follows the -[Flux-inspired practice](https://zustand.docs.pmnd.rs/learn/guides/flux-inspired-practice): -`on*` for SSE-event reactions (`onTextDelta`, `onStreamError`, -`onPollReconciled`), imperative for user/system-initiated actions -(`requestSend`, `cancelGeneration`, `resetTurn`). - -### Selector patterns and `useShallow` - -Selectors control re-render granularity. Choose the right pattern based -on what the selector returns: - -```ts -// 1. Primitive selector — no useShallow needed -const assistantId = useChatStore((s) => s.assistantId); - -// 2. Object/array slice — useShallow required (new reference each call) -const { messages, assistantId } = useChatStore( - useShallow((s) => ({ messages: s.messages, assistantId: s.assistantId })), -); - -// 3. Derived/transformed state — useShallow doesn't help, use useMemo -const unread = useChatStore((s) => s.messages.filter((m) => !m.read)); -// ⚠️ returns new array each time — wrap consumer in useMemo or use -// a custom equality function via createWithEqualityFn. -``` - -Rule of thumb: if the selector returns a **primitive** (`string`, -`number`, `boolean`, `null`), use it directly. If it returns a **new -object or array**, wrap with `useShallow`. If it **derives/transforms** -data, consider `useMemo` in the consumer or a stable selector defined -outside the component. - -References: -- [Zustand — Prevent rerenders with useShallow](https://zustand.docs.pmnd.rs/guides/prevent-rerenders-with-use-shallow) -- [Zustand v5 selector best practices (community discussion)](https://github.com/pmndrs/zustand/discussions/2867) - -### Auto-generated selectors via `createSelectors` - -Wrap every store with `createSelectors()` from `src/utils/create-selectors.ts` -to auto-generate per-field selector hooks. This is the -[official Zustand pattern](https://zustand.docs.pmnd.rs/learn/guides/auto-generating-selectors) -for reducing boilerplate while keeping per-field re-render optimization. - -```ts -import { create } from "zustand"; -import { createSelectors } from "@/utils/create-selectors.js"; - -interface BearState { - bears: number; - increase: (by: number) => void; -} - -const useBearStoreBase = create()((set) => ({ - bears: 0, - increase: (by) => set((state) => ({ bears: state.bears + by })), -})); - -export const useBearStore = createSelectors(useBearStoreBase); -``` - -Consumers use the `.use` property — fully typed, with autocomplete: - -```ts -// Auto-generated selector — one field, minimal re-renders -const bears = useBearStore.use.bears(); -const increase = useBearStore.use.increase(); - -// .getState() still works for non-React contexts (middleware, interceptors) -const { bears } = useBearStore.getState(); -``` - -Prefer `.use.field()` over manual `(s) => s.field` selectors. For -derived/computed values (e.g. `user?.id`), use `.use.user()` and -access the property from the result. See -[Reading state: `.use.*` vs `.getState()`](#reading-state-use-vs-getstate) -for when to use each API. - -Reference: [Zustand — Auto Generating Selectors](https://zustand.docs.pmnd.rs/learn/guides/auto-generating-selectors) - -### Reading state: `.use.*` vs `.getState()` - -Zustand exposes two ways to read store state. Using the wrong one -causes either missed re-renders or unnecessary subscriptions. - -| Context | API | Why | -|---------|-----|-----| -| **React render body** (component/hook top level) | `useStore.use.field()` | Creates a subscription — component re-renders when `field` changes. Required for reactive UI. | -| **Event handlers, callbacks, effects, `useCallback` bodies** | `useStore.getState().field` | Reads the latest value at call time without creating a subscription. No stale-closure risk. | -| **Outside React** (middleware, interceptors, stream handlers, `main.tsx`) | `useStore.getState().field` | No React context available — `.use.*` would throw. | -| **Calling actions** (anywhere) | `useStore.getState().actionName()` | Actions are stable references — calling via `.getState()` is always correct and avoids adding the action to dependency arrays. | - -```ts -// Render body — reactive subscription -const count = useMessageStore.use.count(); - -// Event handler — imperative read + action -const handleClick = useCallback(() => { - useMessageStore.getState().increment(); -}, []); - -// Middleware — outside React -const { isLoggedIn } = useAuthStore.getState(); -``` - -Zustand's `set()` is synchronous — `.getState()` after an action -returns already-mutated values. Read state *before* calling an action -when the caller needs pre-mutation values. - -References: -- [Zustand — Updating state](https://zustand.docs.pmnd.rs/guides/updating-state) -- [Zustand — Reading/writing state outside components](https://zustand.docs.pmnd.rs/guides/extracting-state-outside-components) -- [React — Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks) - -### Data fetching: React Query vs direct SDK calls - -Use **React Query** for data consumed primarily by React components — -it provides stale-while-revalidate, automatic background refetching, -cache sharing between components, and error/loading states. This covers -most API data: chat messages, assistant state, billing, settings, etc. - -Use **direct SDK calls** inside Zustand stores for infrastructure-level -shared state that must be readable outside the React tree (middleware, -API interceptors, loaders) via `.getState()`. This applies when: - -1. **Non-React consumers exist** — middleware or interceptors need the - data synchronously before any component renders. -2. **The fetch is simple** — a single call on login or on demand, - with no benefit from background refetching or cache sharing. -3. **The store is the single source of truth** — no need to sync - between React Query cache and a separate module-level variable. - -Auth and organization state both fit this category. The generated SDK -client (`sdk.gen.ts`) exposes the same typed API functions that React -Query wraps, so switching from `useQuery(optionsFn())` to a direct -`apiFunction()` call uses the same endpoint, types, and interceptors. - -```ts -// Infrastructure store — direct SDK call -import { organizationsList } from "@/generated/api/sdk.gen.js"; - -const useOrgStoreBase = create()((set) => ({ - organizations: [], - fetchOrganizations: async () => { - const result = await organizationsList(); - set({ organizations: result.data?.results ?? [] }); - }, -})); - -// Domain data — React Query (used only in components) -const { data } = useQuery(assistantsListOptions()); -``` - -References: -- [TkDodo — Working with Zustand](https://tkdodo.eu/blog/working-with-zustand) — React Query maintainer's guidance on the boundary between server state (RQ) and client/infrastructure state (Zustand) -- [Zustand — Reading/writing state outside components](https://zustand.docs.pmnd.rs/guides/reading-and-writing-state-outside-components) - -### useReducer is not used for client state - -**Do not use `useReducer` in `apps/web/`.** All client state — including -single-hook-scoped state with non-trivial transitions — lives in a -Zustand store with direct named actions (see -[Direct named actions, not reducers](#direct-named-actions-not-reducers) -just below). The dispatch/action-type/reducer pattern is not the -shape we want even inside a Zustand store — Zustand's -[Flux-inspired practice guide](https://zustand.docs.pmnd.rs/guides/flux-inspired-practice) -exists for Redux migration paths, not as the recommended idiom. - -```ts -// Good — Zustand store with direct named actions -const useSecretStore = createSelectors( - create((set) => ({ - requestId: null, - prompt: null, - showSecret: (requestId: string, prompt: string) => - set({ requestId, prompt }), - dismissSecret: () => set({ requestId: null, prompt: null }), - })), -); - -// Avoid — useReducer in any form. Locks state to one component subtree, -// prevents atomic selectors, no devtools, doesn't survive remount, -// duplicates the React state primitive we already use Zustand for. -const [state, dispatch] = useReducer(secretReducer, initialState); - -// Avoid — dispatcher pattern inside a Zustand store. Zustand supports -// this for Redux migrants but it's not idiomatic; named actions are -// independently testable, discoverable in IDE autocomplete, and don't -// pay the action-type/switch tax. -create((set) => ({ - dispatch: (action: SecretAction) => - set((state) => secretReducer(state, action)), -})); -``` - -Why no `useReducer` and no in-store reducer pattern: - -- **Consistency** — the codebase standardizes on Zustand stores with direct named actions as the single client-state primitive. -- **Cross-component subscribers** — Zustand atomic selectors handle this for free; `useReducer` requires Context wrapping + cross-tree re-renders. -- **Devtools** — Zustand integrates with Redux DevTools; `useReducer` doesn't. -- **Persistence across remounts** — module-level Zustand stores survive route remounts; `useReducer` state doesn't. -- **No prop drilling** — `useReducer` state must be passed down or wrapped in Context. Zustand selectors are accessible everywhere. -- **No dispatcher boilerplate** — direct named actions skip the action-type union, the switch statement, and the runtime cost of an indirection layer. Each action is a plain function that's testable in isolation. - -For state with complex transition rules (state machines), express the -rules as guards inside the named action itself — e.g. `acceptSend` -no-ops if `phase !== "thinking"`. The action stays a plain function; -the rules stay testable in isolation; we don't need a dispatcher -ceremony to enforce them. - -**Known exceptions** (being migrated): - -- `apps/web/src/domains/terminal/use-terminal-state.ts` and - `apps/web/src/domains/terminal/use-terminal-session.ts` still use - `useReducer` + dispatch. Tracked in - [LUM-1748](https://linear.app/vellum/issue/LUM-1748). Do not - pattern-match new code on these files. - -References: -- [Zustand — Auto Generating Selectors](https://zustand.docs.pmnd.rs/guides/auto-generating-selectors) -- [Zustand — TypeScript guide](https://zustand.docs.pmnd.rs/guides/typescript) - -### Direct named actions, not reducers - -Zustand's recommended pattern is **direct named actions** — plain -functions on the store that call `set()`. Do not use dispatchers, -action-type strings, or switch-case reducers. The `redux` middleware -exists for Redux migration paths but is not the idiomatic Zustand -approach. - -```ts -// Good — Zustand-idiomatic direct actions -export const useTurnStore = create()((set, get) => ({ - phase: "idle" as TurnPhase, - activeTurnId: null as string | null, - activeToolCallCount: 0, - - startTurn: (turnId: string) => - set({ phase: "thinking", activeTurnId: turnId }), - - startStreaming: () => - set({ phase: "streaming" }), - - completeTurn: () => - set({ phase: "idle", activeTurnId: null, activeToolCallCount: 0 }), - - incrementToolCalls: () => - set((s) => ({ activeToolCallCount: s.activeToolCallCount + 1 })), -})); - -// Avoid — reducer/dispatch pattern (Redux holdover) -dispatch: (action) => set((state) => turnReducer(state, action)) -``` - -Each action is independently callable, testable, and discoverable via -the store's TypeScript interface. Consumers call -`useTurnStore.getState().startTurn(id)` or select individual actions -via hooks — no action-type constants or switch statements needed. - -References: -- [Zustand — Flux-inspired practice](https://zustand.docs.pmnd.rs/learn/guides/flux-inspired-practice) — "state can be updated without dispatched actions and reducers" -- [Zustand — Updating state](https://zustand.docs.pmnd.rs/learn/guides/updating-state) - ---- ## Component patterns @@ -873,7 +496,7 @@ export function ChatMarkdownMessage(props: ChatMarkdownMessageProps) { For component authoring conventions (React 19 ref-as-prop, `data-slot`, variant patterns, file organization), see -[`packages/design-library/README.md`](../../packages/design-library/README.md). +[`packages/design-library/README.md`](../../../packages/design-library/README.md). References: - [Node.js — Package exports](https://nodejs.org/api/packages.html#exports) @@ -883,31 +506,12 @@ References: --- -## Data fetching - -### React Query for server state - -Use [TanStack React Query](https://tanstack.com/query/latest) for -server-derived data (API calls, caching, background refetching, -mutations). When multiple components need the same data, use a shared -hook with a stable query key — not independent `useState` + -`useEffect` fetch cycles in each consumer. +## API client codegen -React Query handles **server state**. Zustand handles **client state** -(UI interactions, streaming state, conversation selections). They do not -overlap. - -Why React Query over alternatives: -- [HeyAPI `@tanstack/react-query` plugin](https://heyapi.dev/openapi-ts/plugins/tanstack-query) - auto-generates type-safe query/mutation hooks from the OpenAPI spec. - No equivalent plugin exists for SWR (still in proposal stage) or other - libraries. -- First-class mutation support, optimistic updates, and DevTools. -- 12M+ weekly downloads (2026), most feature-complete option. - -References: -- [React Query — Overview](https://tanstack.com/query/latest/docs/framework/react/overview) -- [React Query — Comparison](https://tanstack.com/query/latest/docs/framework/react/comparison) +Server state, React Query usage, and the Zustand-vs-Query boundary are +covered in [`STATE_MANAGEMENT.md`](./STATE_MANAGEMENT.md). This section +is about the **tooling** that produces the API client itself: OpenAPI +codegen, generated hooks, and when to bypass them. ### HeyAPI for OpenAPI client generation @@ -928,10 +532,11 @@ automatically via [npm lifecycle hooks](https://docs.npmjs.com/cli/v10/using-npm - **`predev`** — runs before every `bun run dev`; always regenerates so the client stays in sync with the committed specs. -No manual codegen step is needed — both `bun install` + `bun run dev` and -`vel up --vite` trigger these hooks automatically. +No manual codegen step is needed — `bun install` + `bun run dev` triggers +these hooks automatically. Vellum maintainers using the internal `vel` +CLI also get codegen via `vel up --vite`. -**Vellum developers** updating the specs after platform API changes: +**Vellum maintainers** updating the specs after backend API changes: ```bash ./scripts/sync-openapi-specs.sh # copies from sibling platform checkout diff --git a/apps/web/docs/STATE_MANAGEMENT.md b/apps/web/docs/STATE_MANAGEMENT.md new file mode 100644 index 00000000000..3452bc58176 --- /dev/null +++ b/apps/web/docs/STATE_MANAGEMENT.md @@ -0,0 +1,406 @@ +# Web App — State Management + +How client state and server state are managed in `apps/web/`. Zustand +stores for client state, TanStack Query for server state, atomic +selectors, no `useReducer`. + +See also [`apps/web/AGENTS.md`](../AGENTS.md) and the umbrella +[`CONVENTIONS.md`](./CONVENTIONS.md). + +--- + + + +## Zustand for shared mutable state + +Use [Zustand](https://github.com/pmndrs/zustand) for state shared +across multiple components — messages, turn state, interactions, +conversation list, viewer state. Zustand was chosen over Context + +useReducer because: + +- **Selector support.** `useStore(selector)` lets each component + subscribe to only the slice it needs. Context has no selector + support — every consumer re-renders on any change, which is + unacceptable during streaming (messages update every ~50ms). +- **Framework-agnostic store definitions.** Store logic is plain + TypeScript with no React dependency — portable across environments. +- **Direct named actions.** Store actions are plain functions that + call `set()` — no dispatchers, no action types, no switch statements. + See [Zustand store conventions](#zustand-store-conventions). + +```ts +// Good — component only re-renders when its slice changes +const messages = useChatStore((s) => s.messages); + +// Avoid — every consumer re-renders on any context change +const { messages } = useContext(ChatContext); +``` + +References: +- [Zustand docs](https://zustand.docs.pmnd.rs/) +- [Zustand — Auto-generating selectors](https://zustand.docs.pmnd.rs/guides/auto-generating-selectors) + +## Zustand store conventions + +Each domain owns its store, colocated within the domain folder: +`domains/messages/message-store.ts`. Store files use +`{domain}-store.ts`. Zustand stores are module-level singletons with +both React hook and non-React APIs (`.getState()`, `.setState()`, +`.subscribe()`), so the file describes what the module *is* (a store), +while the exported hook uses the `use` prefix per React's +[Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks). + +References: +- [Zustand — TypeScript guide](https://zustand.docs.pmnd.rs/guides/typescript) +- [Bulletproof React — project structure](https://github.com/alan2207/bulletproof-react/blob/master/docs/project-structure.md) + +Store creation pattern — separate `State` and `Actions` interfaces, +wrap with `createSelectors` for auto-generated per-field hooks: + +```ts +import { create } from "zustand"; + +import { createSelectors } from "@/utils/create-selectors.js"; +import type { Message } from "./types.js"; + +// State — the data +export interface MessageState { + messages: Message[]; + activeThreadId: string | null; +} + +// Actions — direct named functions (no dispatch/reducer) +export interface MessageActions { + addMessage: (message: Message) => void; + setActiveThread: (threadId: string | null) => void; + clearMessages: () => void; +} + +// Combined store type +export type MessageStore = MessageState & MessageActions; + +const useMessageStoreBase = create()((set) => ({ + messages: [], + activeThreadId: null, + addMessage: (message) => + set((s) => ({ messages: [...s.messages, message] })), + setActiveThread: (threadId) => + set({ activeThreadId: threadId }), + clearMessages: () => + set({ messages: [], activeThreadId: null }), +})); + +export const useMessageStore = createSelectors(useMessageStoreBase); +``` + +Consumers use `.use.field()` in render bodies and `.getState()` in +callbacks — see +[Reading state: `.use.*` vs `.getState()`](#reading-state-use-vs-getstate). + +Keep store definitions in their domain folder — adding or removing a +domain means adding or removing a folder. + +References: +- [Zustand — TypeScript guide](https://zustand.docs.pmnd.rs/guides/typescript) +- [Zustand — Auto Generating Selectors](https://zustand.docs.pmnd.rs/learn/guides/auto-generating-selectors) + +## Auth state lives in a Zustand store + +Auth is cross-domain shared state — used by middleware, route +components, API interceptors, and platform bridges. It lives in a +Zustand store (`stores/auth-store.ts`), not a React Context. This +is critical because: + +- **Middleware and loaders** need auth state outside the React tree — + `useAuthStore.getState()` works anywhere; Context requires a + component. +- **API interceptors** need to read/write auth state synchronously. +- **Selector support** — components subscribe to only the auth slice + they need (e.g., `useAuthStore(s => s.isAuthenticated)`). + +References: +- [Zustand — Reading/writing state outside components](https://zustand.docs.pmnd.rs/guides/reading-and-writing-state-outside-components) +- [React Router — Middleware](https://reactrouter.com/how-to/middleware) + +## Turn state lives in `domains/messaging/turn-store.ts` + +Turn lifecycle (sending, thinking, streaming, idle, errored), queue +depth, active tool-call count, and current turn identity are managed +by the turn store. Use `useTurnStore(selector)` in React components +and `useTurnStore.getState()` in non-React code (stream handlers, +reconciliation). Do not prop-drill turn state or dispatch functions. + +Action naming follows the +[Flux-inspired practice](https://zustand.docs.pmnd.rs/learn/guides/flux-inspired-practice): +`on*` for SSE-event reactions (`onTextDelta`, `onStreamError`, +`onPollReconciled`), imperative for user/system-initiated actions +(`requestSend`, `cancelGeneration`, `resetTurn`). + +## Selector patterns + +**New code uses atomic selectors via `createSelectors`** — see the next +section ([Auto-generated selectors via `createSelectors`](#auto-generated-selectors-via-createselectors)). +Atomic selectors per field handle the re-render-granularity problem +without any of the `useShallow` ceremony described below. + +### Legacy: `useShallow` patterns (for migration reference) + +A small number of pre-`createSelectors` call sites still use these +patterns. They're documented here for historical context and to help +migrate them — new code uses atomic selectors instead. + +```ts +// 1. Primitive selector — works without useShallow +const assistantId = useChatStore((s) => s.assistantId); + +// 2. Object/array slice — required useShallow to suppress the +// new-reference-per-render re-render storm. +// Replace in new code with two atomic selectors side-by-side. +const { messages, assistantId } = useChatStore( + useShallow((s) => ({ messages: s.messages, assistantId: s.assistantId })), +); + +// 3. Derived/transformed state — useShallow doesn't help. +// Replace in new code with an atomic selector + useMemo in the consumer. +const unread = useChatStore((s) => s.messages.filter((m) => !m.read)); +``` + +References: +- [Zustand — Prevent rerenders with useShallow](https://zustand.docs.pmnd.rs/guides/prevent-rerenders-with-use-shallow) (reference for legacy call sites) +- [Zustand — Auto Generating Selectors](https://zustand.docs.pmnd.rs/guides/auto-generating-selectors) (the recommended pattern) + +## Auto-generated selectors via `createSelectors` + +Wrap every store with `createSelectors()` from `src/utils/create-selectors.ts` +to auto-generate per-field selector hooks. This is the +[official Zustand pattern](https://zustand.docs.pmnd.rs/learn/guides/auto-generating-selectors) +for reducing boilerplate while keeping per-field re-render optimization. + +```ts +import { create } from "zustand"; +import { createSelectors } from "@/utils/create-selectors.js"; + +interface BearState { + bears: number; + increase: (by: number) => void; +} + +const useBearStoreBase = create()((set) => ({ + bears: 0, + increase: (by) => set((state) => ({ bears: state.bears + by })), +})); + +export const useBearStore = createSelectors(useBearStoreBase); +``` + +Consumers use the `.use` property — fully typed, with autocomplete: + +```ts +// Auto-generated selector — one field, minimal re-renders +const bears = useBearStore.use.bears(); +const increase = useBearStore.use.increase(); + +// .getState() still works for non-React contexts (middleware, interceptors) +const { bears } = useBearStore.getState(); +``` + +Prefer `.use.field()` over manual `(s) => s.field` selectors. For +derived/computed values (e.g. `user?.id`), use `.use.user()` and +access the property from the result. See +[Reading state: `.use.*` vs `.getState()`](#reading-state-use-vs-getstate) +for when to use each API. + +Reference: [Zustand — Auto Generating Selectors](https://zustand.docs.pmnd.rs/learn/guides/auto-generating-selectors) + +## Reading state: `.use.*` vs `.getState()` + +Zustand exposes two ways to read store state. Using the wrong one +causes either missed re-renders or unnecessary subscriptions. + +| Context | API | Why | +|---------|-----|-----| +| **React render body** (component/hook top level) | `useStore.use.field()` | Creates a subscription — component re-renders when `field` changes. Required for reactive UI. | +| **Event handlers, callbacks, effects, `useCallback` bodies** | `useStore.getState().field` | Reads the latest value at call time without creating a subscription. No stale-closure risk. | +| **Outside React** (middleware, interceptors, stream handlers, `main.tsx`) | `useStore.getState().field` | No React context available — `.use.*` would throw. | +| **Calling actions** (anywhere) | `useStore.getState().actionName()` | Actions are stable references — calling via `.getState()` is always correct and avoids adding the action to dependency arrays. | + +```ts +// Render body — reactive subscription +const count = useMessageStore.use.count(); + +// Event handler — imperative read + action +const handleClick = useCallback(() => { + useMessageStore.getState().increment(); +}, []); + +// Middleware — outside React +const { isLoggedIn } = useAuthStore.getState(); +``` + +Zustand's `set()` is synchronous — `.getState()` after an action +returns already-mutated values. Read state *before* calling an action +when the caller needs pre-mutation values. + +References: +- [Zustand — Updating state](https://zustand.docs.pmnd.rs/guides/updating-state) +- [Zustand — Reading/writing state outside components](https://zustand.docs.pmnd.rs/guides/extracting-state-outside-components) +- [React — Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks) + +## Data fetching: React Query vs direct SDK calls + +Use **React Query** for data consumed primarily by React components — +it provides stale-while-revalidate, automatic background refetching, +cache sharing between components, and error/loading states. This covers +most API data: chat messages, assistant state, billing, settings, etc. + +Use **direct SDK calls** inside Zustand stores for infrastructure-level +shared state that must be readable outside the React tree (middleware, +API interceptors, loaders) via `.getState()`. This applies when: + +1. **Non-React consumers exist** — middleware or interceptors need the + data synchronously before any component renders. +2. **The fetch is simple** — a single call on login or on demand, + with no benefit from background refetching or cache sharing. +3. **The store is the single source of truth** — no need to sync + between React Query cache and a separate module-level variable. + +Auth and organization state both fit this category. The generated SDK +client (`sdk.gen.ts`) exposes the same typed API functions that React +Query wraps, so switching from `useQuery(optionsFn())` to a direct +`apiFunction()` call uses the same endpoint, types, and interceptors. + +```ts +// Infrastructure store — direct SDK call +import { organizationsList } from "@/generated/api/sdk.gen.js"; + +const useOrgStoreBase = create()((set) => ({ + organizations: [], + fetchOrganizations: async () => { + const result = await organizationsList(); + set({ organizations: result.data?.results ?? [] }); + }, +})); + +// Domain data — React Query (used only in components) +const { data } = useQuery(assistantsListOptions()); +``` + +### Why React Query (not SWR or others) + +- [HeyAPI `@tanstack/react-query` plugin](https://heyapi.dev/openapi-ts/plugins/tanstack-query) auto-generates type-safe query/mutation/infinite-query hooks from the OpenAPI spec. No equivalent plugin exists for SWR (still in proposal stage) or other libraries — this alone is decisive given our HeyAPI codegen pipeline. +- First-class mutation support, optimistic updates, and Redux-DevTools-style query inspection. +- 12M+ weekly downloads (2026), the most feature-complete option in the React server-state space. +- Boundary with Zustand is documented explicitly — see the section above. React Query handles server state; Zustand handles client state; they do not overlap. + +References: +- [React Query — Overview](https://tanstack.com/query/latest/docs/framework/react/overview) +- [React Query — Comparison](https://tanstack.com/query/latest/docs/framework/react/comparison) +- [TkDodo — Working with Zustand](https://tkdodo.eu/blog/working-with-zustand) — React Query maintainer's guidance on the boundary between server state (RQ) and client/infrastructure state (Zustand) +- [Zustand — Reading/writing state outside components](https://zustand.docs.pmnd.rs/guides/reading-and-writing-state-outside-components) + +## useReducer is not used for client state + +**Do not use `useReducer` in `apps/web/`.** All client state — including +single-hook-scoped state with non-trivial transitions — lives in a +Zustand store with direct named actions (see +[Direct named actions, not reducers](#direct-named-actions-not-reducers) +just below). The dispatch/action-type/reducer pattern is not the +shape we want even inside a Zustand store — Zustand's +[Flux-inspired practice guide](https://zustand.docs.pmnd.rs/guides/flux-inspired-practice) +exists for Redux migration paths, not as the recommended idiom. + +```ts +// Good — Zustand store with direct named actions +const useSecretStore = createSelectors( + create((set) => ({ + requestId: null, + prompt: null, + showSecret: (requestId: string, prompt: string) => + set({ requestId, prompt }), + dismissSecret: () => set({ requestId: null, prompt: null }), + })), +); + +// Avoid — useReducer in any form. Locks state to one component subtree, +// prevents atomic selectors, no devtools, doesn't survive remount, +// duplicates the React state primitive we already use Zustand for. +const [state, dispatch] = useReducer(secretReducer, initialState); + +// Avoid — dispatcher pattern inside a Zustand store. Zustand supports +// this for Redux migrants but it's not idiomatic; named actions are +// independently testable, discoverable in IDE autocomplete, and don't +// pay the action-type/switch tax. +create((set) => ({ + dispatch: (action: SecretAction) => + set((state) => secretReducer(state, action)), +})); +``` + +Why no `useReducer` and no in-store reducer pattern: + +- **Consistency** — the codebase standardizes on Zustand stores with direct named actions as the single client-state primitive. +- **Cross-component subscribers** — Zustand atomic selectors handle this for free; `useReducer` requires Context wrapping + cross-tree re-renders. +- **Devtools** — Zustand integrates with Redux DevTools; `useReducer` doesn't. +- **Persistence across remounts** — module-level Zustand stores survive route remounts; `useReducer` state doesn't. +- **No prop drilling** — `useReducer` state must be passed down or wrapped in Context. Zustand selectors are accessible everywhere. +- **No dispatcher boilerplate** — direct named actions skip the action-type union, the switch statement, and the runtime cost of an indirection layer. Each action is a plain function that's testable in isolation. + +For state with complex transition rules (state machines), express the +rules as guards inside the named action itself — e.g. `acceptSend` +no-ops if `phase !== "thinking"`. The action stays a plain function; +the rules stay testable in isolation; we don't need a dispatcher +ceremony to enforce them. + +**Known exceptions** (slated for migration): + +- `apps/web/src/domains/terminal/use-terminal-state.ts` and + `apps/web/src/domains/terminal/use-terminal-session.ts` still use + `useReducer` + dispatch. These will be migrated to Zustand stores + in a future change. Do not pattern-match new code on these files. + +References: +- [Zustand — Auto Generating Selectors](https://zustand.docs.pmnd.rs/guides/auto-generating-selectors) +- [Zustand — TypeScript guide](https://zustand.docs.pmnd.rs/guides/typescript) + +## Direct named actions, not reducers + +Zustand's recommended pattern is **direct named actions** — plain +functions on the store that call `set()`. Do not use dispatchers, +action-type strings, or switch-case reducers. The `redux` middleware +exists for Redux migration paths but is not the idiomatic Zustand +approach. + +```ts +// Good — Zustand-idiomatic direct actions +export const useTurnStore = create()((set, get) => ({ + phase: "idle" as TurnPhase, + activeTurnId: null as string | null, + activeToolCallCount: 0, + + startTurn: (turnId: string) => + set({ phase: "thinking", activeTurnId: turnId }), + + startStreaming: () => + set({ phase: "streaming" }), + + completeTurn: () => + set({ phase: "idle", activeTurnId: null, activeToolCallCount: 0 }), + + incrementToolCalls: () => + set((s) => ({ activeToolCallCount: s.activeToolCallCount + 1 })), +})); + +// Avoid — reducer/dispatch pattern (Redux holdover) +dispatch: (action) => set((state) => turnReducer(state, action)) +``` + +Each action is independently callable, testable, and discoverable via +the store's TypeScript interface. Consumers call +`useTurnStore.getState().startTurn(id)` or select individual actions +via hooks — no action-type constants or switch statements needed. + +References: +- [Zustand — Flux-inspired practice](https://zustand.docs.pmnd.rs/learn/guides/flux-inspired-practice) — "state can be updated without dispatched actions and reducers" +- [Zustand — Updating state](https://zustand.docs.pmnd.rs/learn/guides/updating-state) + +--- diff --git a/apps/web/STYLE_GUIDE.md b/apps/web/docs/STYLE_GUIDE.md similarity index 98% rename from apps/web/STYLE_GUIDE.md rename to apps/web/docs/STYLE_GUIDE.md index d773439523b..d1622d1b4d5 100644 --- a/apps/web/STYLE_GUIDE.md +++ b/apps/web/docs/STYLE_GUIDE.md @@ -4,8 +4,7 @@ Coding style, naming conventions, and formatting rules for the Vellum web app. For architectural decisions and patterns see [`CONVENTIONS.md`](./CONVENTIONS.md). -Subordinate to [`apps/AGENTS.md`](../AGENTS.md) and root -[`AGENTS.md`](../../AGENTS.md). +See also [`apps/web/AGENTS.md`](../AGENTS.md) for the quick-rules entry point, and broader patterns in [`apps/AGENTS.md`](../../AGENTS.md) / root [`AGENTS.md`](../../../AGENTS.md). --- @@ -47,7 +46,7 @@ export is a store — a module-level singleton with both React (`useChatStore(selector)`) and non-React (`.getState()`, `.setState()`, `.subscribe()`) APIs. Store files use `{domain}-store.ts`. See -[CONVENTIONS.md — Zustand store conventions](./CONVENTIONS.md#zustand-store-conventions). +[STATE_MANAGEMENT.md — Zustand store conventions](./STATE_MANAGEMENT.md#zustand-store-conventions). Reference: [React — Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks)