# langalpha web

Frontend for langalpha — React 19 + Vite + TypeScript SPA. Talks to the FastAPI backend over REST (`api/client.ts`, a thin `fetch` client) + SSE (raw fetch). Path alias `@` → `src/` is declared once, as `paths` in the tsconfig projects, and Vite and Vitest read it through `resolve.tsconfigPaths`; `@e2e/` exists only in `tsconfig.vitest.json`, so app code cannot import the e2e fixtures.

> 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 -b && vite build && check-critical-path — typecheck and first-load budget both gate the build
pnpm typecheck    # tsc -b over the app, test and node projects (gated in CI)
pnpm test         # vitest run;  test:e2e = Playwright
pnpm lint         # ESLint 10 flat config, cached in node_modules/.cache; errors gate CI, warnings are advisory
```

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 API client 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 the API client** (the client reads whole bodies): `streamFetch()` in `pages/ChatAgent/utils/api/transport.ts`, which the market chat panel streams through too. Its token comes from `lib/authToken.ts`, the same shared cache the API client uses. Most *authenticated* REST goes through the shared client (`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 the client.
- **`api/client.ts` is a thin `fetch` wrapper that keeps axios's shape on purpose**, because ~200 call sites and every error reader (`apiErrorDetail`, `apiErrorStatus`, the 429 `rateLimitInfo`/`retryAfter` readers) were written against it. A call resolves `{ data, status, headers }` with lower-cased header names; a non-2xx rejects an `ApiError` carrying `response: { status, data, headers }`; an abort rejects with `name === 'CanceledError'`; the messages are axios's (`Request failed with status code N`, `timeout of Nms exceeded`, `Network Error`), since call sites show `err.message`. `params` take scalars only, in axios's encoding (`:` `,` `$` literal, space as `+`), which is what the backend has always received; a repeated key goes as `URLSearchParams`. Never set `Content-Type` on a `FormData` body: the browser writes the multipart boundary. `onUploadProgress`/`onDownloadProgress` move that one call onto XHR, the only transport that reports upload progress.
- **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 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-data|skill|file`, normalizing `file://`, `/home/(workspace|daytona)/`, `./`, `__wsref__/<wsid>/…` cross-workspace refs; `user-data` is a data file under `USER_DATA_DIRS`, the profile files and one file per automation) + `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.
  - **The layout literals are generated, not typed.** `agentPaths.generated.ts` is emitted by `scripts/gen_agent_paths.py` from `src/ptc_agent/core/paths.py` (directory names, the sandbox roots, the system-dir list) and `agentPaths.ts` re-exports it. `tests/unit/core/test_sandbox_layout.py` regenerates and diffs the file, so a hand edit fails CI; change `paths.py` and regenerate.
  - **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.
  - **A folder on the same computer names its workspace.** Workspaces on one computer are sibling folders, so the agent reaches another's files as `../<folder>/x` (or `/home/workspace/<folder>/x`), and `siblingWorkspacePath` routes that as a `__wsref__` link to it would. The view's own folder (`../<own>/x`) folds to `x`, since Bash reads it so. Folders match as the server's file routes match them: the current name exactly, a former one by its name key, current names first. Every surface reads the folders from one `ComputerFolders` value (`computerFolders`, `useComputerFolders`, carried by `WorkspaceContext`), so a new surface takes that value rather than the folder names one by one.
- **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 chunk group (`scripts/chunking.ts`) that captures a dependency the entry needs pins the whole group 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 ~27 kB gz. That client is auth-js's `AuthClient` alone, given `@supabase/ssr`'s cookie storage and the options `createBrowserClient` would pass, because the full client put postgrest, realtime, storage and functions on first paint (~31 kB gz) for code that only calls `.auth`. `lib/__tests__/supabaseCookies.test.ts` pins its cookies byte for byte against `createBrowserClient`'s, and the export is typed `{ auth }` so a `.from()` or a realtime channel is a deliberate new client, not a quiet regrowth of this one.
- **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; the app hands a boot failure its listeners cannot see back through `__LA_RECOVER__`. `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 `versionManifest` 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.
- **The React Compiler compiles the app and the unit suite** (`scripts/react-plugins.ts`; `REACT_COMPILER=off` builds and tests without it, which is how to tell a compiler regression from an app one). It caches each computation in a component on that computation's inputs, so whatever render reads behind its back freezes at the first value it saw: `Date.now()`, the module `i18n` instance, a DOM attribute, a ref, a mutable object that keeps its identity. Forcing a re-render does not help, since the tick that forced it is not an input. Hand render what it depends on: the language from `useLocale()`, the time from `useNow()`, the theme from `useTheme()`, and anything else as state or a `useSyncExternalStore` snapshot with a new identity per change. A dependency listed in `useMemo` but never read in its body is dropped, so a version counter must reach the computation itself. Functions the compiler cannot compile (a ref read in render, a `try`/`finally` anywhere in the component, a `react-hooks` eslint suppression) run as before, so a trap can sit unnoticed in one until a refactor makes it compile; the `react-hooks/*` lint warnings are that list.
- **i18n:** locale lives in a `locale` cookie (cookie → browser → `en-US`), no live cross-tab sync. Every formatter in `lib/format.ts` takes the locale as an argument, from `useLocale()`, and `relativeTime` takes `now` from `useNow()`; a helper that reads `i18n.language` itself keeps the old language after a switch. `useSyncUserLocale` can switch the language after first load with no user action, so this is not only a Settings concern.
- **Catalogs load on demand, one chunk per locale.** `main.tsx` renders only after `initI18n()` has the active one, so the first paint never shows keys, and a catalog that fails leaves `#root` to the pre-boot recovery. Change language through `switchLocale()` (`lib/locale.ts`), never `i18n.changeLanguage`: it waits for the chunk and keeps the current language if the chunk is gone. There is no fallback language, so a key missing from `zh-CN.json` renders as the key; `locales/__tests__/keys.test.ts` fails on one. `scripts/locale-preload.ts` has `index.html` fetch the active catalog alongside the entry, inlining the same `resolveLocale()` (`lib/resolveLocale.ts`) the app boots with (pinned by `lib/__tests__/localePreload.test.ts`), and `check-critical-path.mjs` counts the larger catalog in the first-load sum. Unit tests hand both catalogs to `initI18n` in `test/setup.ts`, so a switch there is synchronous. The page intros' copy is a second namespace, `intros` (`locales/intros/`), fetched the first time an intro opens rather than on every load; a step's keys are `<intro id>.<step id>.title` and `.body`, and `pages/Onboarding/__tests__/introCopy.test.ts` checks both locales against the registry.
- **`typescript` in `package.json` is the TS 6 API; `tsc` is TS 7.** TS 7.0 is a native compiler with no JS API, and `@typescript-eslint` throws on import when `typescript` resolves to 7. So `typescript` aliases `@typescript/typescript6` (the TS 6 API for ESLint; its CLI is `tsc6`), and `@typescript/native` aliases `typescript@7`, which owns the `tsc` bin that `pnpm typecheck` and `pnpm build` run. This is the side-by-side setup from the TS 7.0 release notes: collapse it to a plain `typescript@^7` only once typescript-eslint supports TS 7 (typescript-eslint#10940). Separately, TS 6+ defaults `types` to `[]`, so an `@types/*` package that only adds globals or ambient `declare module`s must be listed in the project's `types`, or its declarations drop out of the program.
- **`tsconfig.json` only lists three projects, so `tsc --noEmit` against it checks nothing; run `tsc -b` (`pnpm typecheck`).** `tsconfig.app.json` is the app: `src` minus tests, DOM types only, so `process`, `Buffer` and a `NodeJS.Timeout` from `setTimeout` fail there as they would in the browser. `tsconfig.vitest.json` is the tests plus `src/test`, which run in Node and add its types. `tsconfig.node.json` is the config files, `scripts/*.ts`, the gate script and the e2e global setup, Node types only; its JavaScript is checked through JSDoc. A module a test and the app share must pass both. The e2e specs and `scripts/perf-summary.mjs` are in no project.

## 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 4 (CSS-first config in `src/index.css`, v3 defaults kept in `src/styles/tailwind-v3-theme.css`) + 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`.
  - **Browser floor: Chrome/Edge 111, Safari 16.4, Firefox 128.** Tailwind 4 sets it (cascade layers, `@property`, `color-mix()`); Vite 8's default build target alone would allow Firefox 114. Below the floor the page still loads but styles break, so a feature above it needs a fallback rather than a polyfill.
  - **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`).
  - `vitest.config.ts` splits the suite into two projects. A new file runs under jsdom in the vm pool. A DOM-free file declares `// @vitest-environment node` on its first line, where reading any browser global fails the file (`src/test/browserTraps.ts`, armed by `src/test/setup.ts` when there is no `window`). A file that replaces `window` or `window.location`, or needs Node's `ReadableStream`, goes in `FORKS_FILES`, since a vm context's global is the jsdom window itself.
  - Playwright specs import `test` from `e2e/fixtures.js`, which gives each worker its own mock server and routes the app's API origin to it. On bare `@playwright/test`, an API call reaches a port nothing listens on.
- **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 gates only its errors.** Keep `pnpm typecheck` green (it gates build + CI) and lint at zero errors (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 the API client and raw streams (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) |
| `VITE_CONTACT_EMAILS` | (unset = no contact rows) | Comma-separated support addresses for the dashboard help popover; the first is also the spec dialog's "Contact support" link |
| `VITE_PRIVACY_URL` | (unset = no privacy link) | Address of the privacy policy. The sign-in and legal pages link to it, and `/privacy` forwards to it |
