AGENTS.md@web · git:20260918.a29f221 · 2026-09-18 · sha256 67399f2141770df4
AGENTS.md@web git:20260918.a29f221A
Immutable. This exact content is served forever at /api/v1/blob/67399f2141770df4.
# langalpha web
Frontend for langalpha — React 19 + Vite + TypeScript SPA. Talks to the FastAPI backend over REST (axios) + SSE (raw fetch). Path alias `@` → `src/` (wired in both `vite.config.js` and `vitest.config.ts`).
> Single source of truth for AI coding agents in `web/`. `CLAUDE.md` imports this via `@AGENTS.md`; Codex/Cursor read it directly. Edit here, not there.
## Commands
```bash
pnpm dev # dev server on 127.0.0.1:5173 (proxies /api/v1 + /ws/v1 → VITE_PROXY_BACKEND, default :8000)
pnpm build # tsc --noEmit && vite build && check-critical-path — typecheck and first-load budget both gate the build
pnpm typecheck # tsc --noEmit (gated in CI)
pnpm test # vitest run; test:e2e = Playwright
pnpm lint # ESLint 9 flat config (advisory — NOT gated in CI)
```
Streaming perf benchmarks live in `e2e/perf/`, are gated behind `PERF=1`, and their flags and canonical invocations are in [`e2e/perf/README.md`](e2e/perf/README.md).
## Landmines (non-obvious)
- **Dual-mode auth** (`contexts/AuthContext.tsx`), switched by `VITE_HOST_MODE` (`config/hostMode.ts`, default `oss`): `platform` → `SupabaseAuthProvider` (real session; the axios interceptor reads its Bearer token from `lib/authToken.ts`); `oss` → static local-dev context, always logged in as `VITE_AUTH_USER_ID` (default `local-dev-user`). `VITE_SUPABASE_URL`/`_KEY` only gate Supabase-*client* construction, NOT the mode — check `isPlatformMode`, never `VITE_SUPABASE_URL`.
- **Chat/market SSE uses raw `fetch()` + `ReadableStream`, NOT axios** (axios can't stream) — `streamFetch()` in `pages/ChatAgent/utils/api/transport.ts` + `pages/MarketView/utils/api.ts`. Its token comes from `lib/authToken.ts`, the same shared cache the axios interceptor uses. Most *authenticated* REST goes through the shared axios instance (`api/client.ts`, auto-Bearer, base `VITE_API_BASE_URL`) — but public/unauthenticated calls (`pages/SharedChat/api.ts`), `auth/sync`, and market-data **WebSocket** use raw `fetch`/`WS`, not axios.
- **Every access token comes from `lib/authToken.ts`, and nothing else calls `supabase.auth.getSession()` per request.** `getSession()` is not an accessor: its own docs say it "returns the session, refreshing it if necessary", so calling it per outbound request turned one cold load into ~20 network refreshes. On a device whose clock is off by more than `JWT_TTL - 90s` every read looks near-expiry, which exhausts Supabase's per-IP token budget (1800/hr, burst 30, not customizable) until a 429 arrives, and auth-js treats a 429 as fatal and destroys the session. So: read the cached token, never the session. `AuthContext` owns the **only** `onAuthStateChange` subscription and pushes into the cache synchronously; do not add a second subscriber, because each registration runs `_emitInitialSession` and can itself refresh. `lib/authFetch.ts` is the client's `fetch`: it strips the server's `expires_at` so auth-js recomputes expiry from `expires_in` on the local clock (skew cancels only if one clock does both halves), and it converts a 429 on the token endpoint into a retryable 503 while holding the endpoint closed for `Retry-After`. A pre-expiry margin belongs in `authToken`, never in a caller. The breaker is scoped to `grant_type=refresh_token`, because GoTrue multiplexes five grants onto that one path and signing in is how a user recovers from a storm. One caveat worth knowing before you deploy a second app beside this one: the correction lives in this bundle's JS, but the session lives in a cookie keyed by `cookieOptions.name`. Any other app sharing that origin and that key shares the session itself, and an app without this fetch can still take a fatal 429 and delete the cookie out from under this one. Give every app on the origin the same fetch, or give them different cookie names.
- **A dropped link and an unhappy network are handled differently on the SSE reconnect path** (`pages/ChatAgent/session/stream/lifecycle.ts`). Retries only spend against a network that is *present*; with `navigator.onLine` false the loop waits on the `online` event for up to 5 minutes instead, because the turn keeps running server-side and reconnect resumes it gaplessly from the cursor. Budgeting retries against a dead link is what used to truncate an answer ~15s in. Connectivity helpers live in `lib/network.ts` (`isOnline`/`waitForOnline`) — `useNetworkStatus` and the app-wide `NetworkBanner` (mounted once in `components/Main`) read the same source.
- **The app shell owns the viewport; route roots must not.** `.app-layout` is the only `100vh` box (`App.css`); `.app-main` → `.main` → the animated route wrapper are all `height: 100%` beneath it. A route root that pins itself to the viewport instead (`h-screen`, `height: 100vh`) looks identical until something else takes column height — the app-wide `NetworkBanner` does — and then it overflows the shrunken column by exactly that height, `.app-main` (`overflow: auto`) grows a scrollbar, and the bottom of the page (the chat composer) sits below the fold. Size route roots with `h-full` / `height: 100%`; `calc(100vh - …)` maxima *inside* a route's own scroll container are unaffected. **Measure the shrink point, not `.app-main`.** The shrink is done by a wrapper carrying `minHeight: 0` in `components/Main` — one per branch, and the mobile branch must also be a flex column because the mobile dashboard is `height: auto` and gets its height from being a flex item. On mobile `.app-main` reserves the bottom tab bar as `padding-bottom`, so a route that overflows by the banner's height slides *under* the tab bar and `.app-main`'s own overflow stays 0: check `.main` there. The desktop shell gives the same rule a second cause: a reserved titlebar inset makes a frameless window's viewport taller than its content box, so `vh` units overflow by exactly the inset (`100dvh` stays right where the mobile toolbar is the concern).
- **The window-chrome contract with the desktop shell is decided in `index.html`, not in the bundle.** A `<meta name="langalpha-window-chrome">` declares whether this build reserves the strip the macOS window buttons float over — a declaration, because the reservation is only painted once the shell says the titlebar is gone, so a shell that measured it would only find its own last answer. An inline script in `index.html` reads the shell's preload bridge and stamps `html.desktop-mac`, and every downstream rule is gated on that class in CSS (`styles/chrome.css`, `App.css`, `Sidebar.css`, the `#window-drag` block in `index.html`), never on the bridge and never on a React branch: the class is stamped once before the bundle runs and cannot change, so a JS condition on it would be a second mechanism for a decision that already has one, and the strip the sidebar renders could disagree with the one `chrome.css` paints. `#window-drag` also lives in `index.html` so a window whose entry chunk never ran is still movable. Treat the bridge as an enhancement and feature-detect each method — the shell ships on its own slow cadence while this app deploys continuously, so a new web build must never require a new shell.
- **Agent artifact path routing has ONE source of truth: `pages/ChatAgent/utils/agentPaths.ts`.** `classifyAgentPath` (→ `memory|memo|user-profile|skill|file`, normalizing `file://`, `/home/(workspace|daytona)/`, `./`, `__wsref__/<wsid>/…` cross-workspace refs) + `computeAgentArtifactRouting` (pure: which panel tab/key/workspace to open). Add new path types here, not in panel components — each new location duplicates the normalization rules.
- **A destination and a path are read differently, and each string gets one reading.** `normalizeAgentHref` reads a markdown destination: it drops a `?query`/`#fragment` and percent-decodes, both exactly once. `parseAgentPath` / `normalizeAgentPath` read a path and are idempotent. The rule is not stylistic: decoding turns `%23` into a literal `#`, so a second URL reading of the result eats the rest of the name and `results/issue#1.md` becomes `results/issue` — no extension, no card, and a read for a file nothing wrote. Whatever holds the string chooses the reading; nothing downstream re-reads it. The same split is why `fileExtension` takes a path and `isImagePath` splits the location off itself.
- **A relative path keeps the levels it climbs.** `../data.csv` normalizes to `../data.csv`, not `data.csv`: folding it away names a different file that often exists, so the link opens the wrong document rather than missing, and `fileRefResolver.linkCandidates` loses the one thing it joins against the viewing file's directory. Only a rooted or sandbox path clamps, having nowhere to climb.
- **Zod validates untrusted *persisted/user* input at the boundary, not API responses.** Widget prefs (schemas in `configSchemas.ts`, applied via `safeParse` in `migrations.ts`), onboarding prefs, MCP config — all `safeParse` + per-field `.catch()` (never throw). Typed API responses are plain TS interfaces, not runtime-validated.
- **First-load bundle is asserted, not eyeballed** (`scripts/check-critical-path.mjs`, gated in `pnpm build` + CI). Vite's build table lists every chunk as if it were lazy; only what `dist/index.html` references is actually on the critical path. A `manualChunks` entry naming a *lazy* vendor pins it to the entry — that is how the chart bundle rode first paint for five months. If the guard trips, fix the import graph; bump `EXPECTED`/`MAX_EAGER_KB` only deliberately. Reproducing the shipped bundle needs `VITE_HOST_MODE=platform` **and** `VITE_SUPABASE_URL`/`_PUBLISHABLE_KEY` — `lib/supabase.ts` guards the client behind `url && key`, so unset vars tree-shake the whole SDK out and understate first load by ~57 kB gz.
- **Stale-build recovery spans four layers that cannot see each other.** A deploy swaps every content-hashed filename, so a tab still holding the previous document asks for chunks that no longer exist. The pre-boot half is an inline ES5 IIFE in `index.html` — inline because the bundle is what failed, ES5 because a parse error there would disable recovery on exactly the browsers most likely to be stale, and in `<head>` because the listener must beat the entry script. It hands off to the app through three globals: `__LA_BOOTED__` (set by `markBooted()`, and the only thing stopping a reload from discarding a streaming turn), `__LA_STALE_BUILD__`, and the `la:stale-build` event. `lib/staleBuild.tsx` owns everything post-boot and re-implements the pre-boot classifier in TypeScript. Since `index.html` never reaches tsc, two test files pin the halves together, both by reading its source text: `lib/__tests__/staleBuild.test.tsx` asserts the shared literals (prefix, regex anchoring, event name) still match, and `lib/__tests__/staleBuildPreBoot.test.ts` lifts the IIFE out and runs it in its own jsdom, so the two classifiers are exercised against the same messages and the reload bound is reachable at all. **Same-origin `/assets/` is a hard precondition:** setting `VITE_CDN_BASE` moves the build off this origin and every layer silently classifies a real dead asset as somebody else's problem.
- **`/version.json` is build output with serving guarantees this repo cannot enforce.** The vite plugin `emitVersionManifest` writes the entry filename; `check-critical-path.mjs` asserts it matches `dist/index.html` and that there is exactly one module script (a second one would be read as the entry and give every user a permanent "new version" prompt). At the edge it must 404 on a miss rather than fall through to the SPA shell, carry `application/json`, and never be cached — and `index.html` itself must not be long-cached, or the recovery reload refetches the same dead asset names. Those rules live in the deploy-time serving config and `public/_headers`, which are untracked here, so nothing in this repo will tell you when they break: the client fails closed and silent by design, with one `console.warn` on an unreadable manifest as the only operator signal.
- **i18n re-render gotcha:** locale lives in a `locale` cookie (cookie → browser → `en-US`), no live cross-tab sync. Components that format numbers/dates via `createFormatter`/`createDateFormatter` (`lib/format.ts`) MUST also call `useTranslation()`, or they won't re-render on a locale switch.
## Conventions
- **API layering:** each page group owns its calls in a local `utils/api.ts` (`ChatAgent`, `Dashboard`, `MarketView`, `Automations`); cross-page data goes through shared hooks in `hooks/`.
- **React Query:** hierarchical key factory in `lib/queryKeys.ts` enables prefix invalidation (e.g. invalidate `queryKeys.user.all`). Dashboard prefs write back through a guarded writer that survives cross-tab races + cold-cache mounts.
- **Styling:** Tailwind 3 + theme-aware CSS custom properties (`var(--color-*)`) used directly in style props; `cn()` (clsx + tailwind-merge) for conditional classes; Radix primitives in `components/ui/` via `class-variance-authority`.
- **Design system** — palette, typography, accent discipline, and status/liveness vocabulary are defined in the repo-root [`DESIGN.md`](../DESIGN.md) ("Quiet Workspace"). Read it before styling anything user-visible.
- **Background roles** — pick by surface, not by matching a hex: `bg-page` (app ground) → `bg-canvas` (ground under a card grid) → `bg-card` / `bg-tool-card` (cards on it) → `bg-elevated` (menus, tooltips) → `bg-input` (fields) → `bg-popover` (Radix popover/select). The full table is the comment above the background group in `styles/tokens.css`.
- Floating surfaces deliberately do **not** share one fill yet (popover/select on `--popover`, tooltips + menus on `bg-elevated`, four dialogs still on `bg-page`); unification is deferred pending a side-by-side visual call — don't converge one of them in isolation.
- Every `var(--color-*)` must be declared in `tokens.css` (`styles/__tests__/tokenRefs.test.ts` fails on undeclared names); canvas painters that can't read CSS variables go through `lib/themeTokens.ts`, never a fresh hex literal.
- **Tests:** co-located in `__tests__/` next to the code; Vitest + jsdom + Testing Library. Global setup mocks `matchMedia`/`IntersectionObserver`/`ResizeObserver` (`src/test/setup.ts`).
- **Side-by-side ChatView + FilePanel headers must stay height-aligned** — if you touch either header's padding/icon size, verify they still line up (`FilePanel.css` `file-panel-header`).
## Working principles
- **Keep API calls in the api layer, not in components.** Endpoint/fetch calls belong in a page's `utils/api.ts` or a shared `lib/*` client module (e.g. market data in `lib/bars`, `lib/quotes`) — never inline in a component. Server-state access goes through `hooks/` + React Query; React-lifecycle singletons through `contexts/`. Components compose `components/ui/` primitives (they don't hand-roll Radix).
- **One source of truth — don't duplicate the cross-cutting modules.** Agent-path logic → `agentPaths.ts`; query keys → `queryKeys.ts` (never inline key arrays — it breaks prefix invalidation); locale/formatters → `lib/locale.ts` + `lib/format.ts` (never ad-hoc `Intl.*`); class merging → `cn()` (never concatenate class strings).
- **Server state is React Query; validate untrusted input at the boundary.** Don't mirror server data into local state; invalidate by key prefix. Zod (`safeParse` + `.catch`, never throwing) guards only *persisted/user* input — never runtime-validate trusted API responses, never `.parse()` at a boundary. Module-level singletons that outlive React must be reset on logout.
- **Types are the hard gate, lint is soft.** Keep `tsc --noEmit` green (it gates build + CI); avoid `any` — narrow `unknown` instead — even though ESLint won't stop you.
## Env
| Variable | Default | Purpose |
|---|---|---|
| `VITE_HOST_MODE` | `oss` | `platform` → Supabase auth mode; `oss` → local-dev, no auth |
| `VITE_API_BASE_URL` | (empty = same-origin) | Backend base URL for axios (dev: same-origin, proxied) |
| `VITE_PROXY_BACKEND` | `http://localhost:8000` | Dev-server proxy target for `/api/v1` + `/ws/v1` |
| `VITE_DEV_ALLOWED_HOSTS` | (unset = Vite default) | Comma-separated extra Host headers the dev server accepts, for tunnels (ngrok etc.) |
| `VITE_SUPABASE_URL` | — | Supabase project URL (gates client creation, not mode) |
| `VITE_SUPABASE_PUBLISHABLE_KEY` | — | Supabase anon key |
| `VITE_AUTH_USER_ID` | `local-dev-user` | User id in `oss` mode |
| `VITE_CDN_BASE` | `/` | Asset base for CDN builds. **Disables stale-build recovery** — every layer assumes same-origin `/assets/` (see Landmines) |
| `VITE_COOKIE_DOMAIN` | (unset = host-only) | Parent domain to share auth/locale cookies across subdomains |
| `VITE_APP_ENTRY_PATH` | `/app` (platform) / `/` (oss) | Where the SPA entry (login + anonymous bounce) mounts; set `/` on a dedicated app subdomain |
| `VITE_PLATFORM_URL` | `/account` | Platform console origin/path for account, plans and integrations links. Absolute origin on a split-host deploy; **no trailing slash** (call sites concatenate) |