A pure SPA, by choice.
React 19 + Vite + TypeScript + Tailwind. No SSR, no server-rendering, no router library. The bet: keep the surface small, ship features fast, type the API contracts strictly. Static hosting works; deep links work; refresh works; file:// works.
Top-level structure
frontend/ ├── src/ │ ├── pages/ — top-level routes (one component per route) │ ├── components/ — shared UI: chat, approval cards, doc viewer, terminal, sync, admin │ ├── hooks/ — data-fetching + integrations (one per concern) │ ├── contexts/ — AuthContext only │ └── lib/ — api.ts, wsClient.ts, router.ts, auth.ts, cn.ts ├── index.html ├── vite.config.ts ├── tailwind.config.js └── package.json
Routing
frontend/src/lib/router.ts — ~123 lines
A custom hash router. There is no react-router. The hash is the source of truth for the current route; useRoute() parses it on every render. Helper functions like routeToProject(projectId, chatId?), routeToTerminal() mutate window.location.hash directly.
| Hash route | Page component | Purpose |
|---|---|---|
#/ · #/chats/{chatId} | ChatsPage | Workspace-level standalone chats |
#/projects | ProjectsPage | Project list + create modal |
#/projects/{id} · #/projects/{id}/chats/{cid} | ProjectPage | Project hub: chats, instructions, artifacts, sync |
#/terminal/{sid} | TerminalPage | xterm.js shell session |
#/settings (+ /<path…>) | SettingsPage | Tenant / user settings, integrations, MCP |
#/admin | AdminPage | Tenant-admin console |
#/scheduled | ScheduledPage | Scheduled-job CRUD + history |
#/login | LoginPage | OAuth or local login |
#/auth-callback | handled by AuthContext | Receives ?token=… from OAuth redirect |
#/access-denied | AccessDeniedPage | Permission failures |
Auth context
frontend/src/contexts/AuthContext.tsx · frontend/src/lib/auth.ts
- Storage
localStorage["agenticit_token"]. Cleared on 401 with redirect to#/login.AUTH_ENABLED- Vite env flag (
VITE_AUTH_ENABLED). When false, the token is optional — useful for some local-dev paths. - Bootstrap
- If the URL hash is
#/auth-callback?token=…, extract token, store it, redirect to#/. Then callGET /api/auth/meto populate theuserobject. - login(provider)
- Redirects to
/api/auth/<provider>. The backend handles OAuth and round-trips back via#/auth-callback. - loginWithCredentials
- POSTs to
/api/auth/local/login; same token shape as OAuth. - logout
- POSTs to
/api/auth/logout, clears the token, redirects.
Build & dev
frontend/vite.config.ts — ~18 lines
- Dev port
VITE_PORT, default 3000. Parallel-worker dev uses 3000 + WORKER_ID·100.- Proxy
/apiand/ws→VITE_API_TARGET(defaulthttp://localhost:8000). WebSocket upgrade is handled by Vite's proxy.- Production build
tsc -b && vite build. Output indist/; served as static assets.VITE_API_URL- Compile-time base URL for both REST and WS. Empty in dev (uses proxy); set to the production API host in deployed builds.
Why no router library
The router is ~123 lines of pure parsing. Adding react-router would buy us:
- Nothing — we already have data-fetching hooks per page; no need for a "Loaders" abstraction.
- Heavier bundles, more API surface, more upgrade churn.
The cost of staying lean is occasional duplication when computing breadcrumbs or active-route highlighting. Worth it.
Why hash routing
- Static hosting just works — no server-side rewrite rules.
- Refresh on a deep route hits the same
index.html. file://works during local debugging.- OAuth round-trip carries a hash fragment back without server cooperation.