Frontend Architecture¶
Technical spec for the aaiclick web UI: framework, build, data layer, and
real-time updates. UX (layout, modes, wireframes) lives in docs/ui.md.
Tech stack¶
| Layer | Choice | Why |
|---|---|---|
| UI framework | React 19 + TypeScript | Largest ecosystem, first-class TanStack Query |
| Styling | TailwindCSS 4 | Utility-first, no design-system overhead |
| Build | Vite 6 | Fast HMR, native ESM, zero-config TS |
| Data fetch | TanStack Query 5 | Caching, retries, refetchInterval polling |
| Real-time | REST polling (v0) | 2 s refetchInterval; SSE deferred (see below) |
| Client state | None (URL is the state) | The prompt drives navigation; no Redux/Zustand |
Project layout¶
package.json lives at the repo root alongside the Python package.
TypeScript source lives at src/ (Vite default).
package.json, vite.config.ts, tsconfig.json, index.html ← repo root
src/ ← SPA TypeScript source
main.tsx, App.tsx ← providers + prompt router
prompt.ts ← URL ↔ route parser
api/ ← typed REST client + React Query hooks
types.ts ← TypeScript mirrors of pydantic view models
client.ts ← fetchJSON / postJSON + ApiError
hooks.ts ← useJobs, useJob, useTask, useTaskLogs, …
views/ ← one file per UI mode
components/ ← StatusBadge, ProgressBar, LogViewer, …
styles/globals.css ← Tailwind import + ported mockup theme
aaiclick/
server/
static/ ← Vite build output (gitignored)
node_modules/ ← gitignored
Python tooling (ruff, pyright, pytest) is configured to skip src/
and node_modules/.
Build & dev workflow¶
| Command | What it does |
|---|---|
npm run dev |
Vite dev server at :5173 with HMR; proxies /api/* to FastAPI |
npm run build |
Type-checks then bundles to aaiclick/server/static/ |
npm run check |
tsc --noEmit only (CI gate) |
In production, FastAPI mounts aaiclick/server/static/ and serves
index.html for unknown routes (SPA fallback). One process, one port,
no CORS.
Build output is gitignored
aaiclick/server/static/ is regenerated by npm run build.
Never commit it. Release wheels include it via the build step.
Data layer¶
REST is the sole source of truth in v0. Every hook polls every 2 seconds
via TanStack Query's refetchInterval.
- Typed REST client:
src/api/client.ts—fetchJSON/postJSON+ApiError. - TypeScript types:
src/api/types.ts— hand-written mirrors of the pydantic view models (JobView,JobDetail,TaskDetail,TaskLogs, etc.). - React Query hooks:
src/api/hooks.ts—useJobs(),useJob(ref),useTask(id),useTaskLogs(id),useRegisteredJobs(), plus mutation hooks for run / cancel / register. - No global store: the URL query param
?p=drives which view mounts; React Query owns server state; component state holds only UI ephemera.
Backend endpoints consumed:
| Hook | Endpoint | Router |
|---|---|---|
useJobs |
GET /api/v0/jobs |
aaiclick/server/routers/jobs.py |
useJob |
GET /api/v0/jobs/{ref} |
aaiclick/server/routers/jobs.py |
useTask |
GET /api/v0/tasks/{id} |
aaiclick/server/routers/tasks.py |
useTaskLogs |
GET /api/v0/tasks/{id}/logs |
aaiclick/server/routers/tasks.py |
useRegisteredJobs |
GET /api/v0/registered-jobs |
aaiclick/server/routers/registered_jobs.py |
useRunJob |
POST /api/v0/jobs:run |
aaiclick/server/routers/jobs.py |
useCancelJob |
POST /api/v0/jobs/{ref}/cancel |
aaiclick/server/routers/jobs.py |
useRegisterJob |
POST /api/v0/registered-jobs |
aaiclick/server/routers/registered_jobs.py |
Implementation: aaiclick/server/routers/tasks.py — see get_task_logs;
aaiclick/internal_api/tasks.py — see get_task_logs (reads task.log_path,
returns available=False when the file is missing or cross-host);
aaiclick/orchestration/view_models.py — see TaskLogsView, JobView
(total_tasks / completed_tasks populated by list_jobs).
Real-time (v0 — REST polling)¶
v0 uses refetchInterval: 2000 on every query. No SSE endpoint exists yet;
design and fanout spec are tracked in docs/future.md.
SSE design (future)¶
One SSE connection per UI session. The server emits typed events; the client invalidates React Query caches and lets REST refetch authoritative state.
- Endpoint:
GET /api/v0/events→text/event-stream - Client dispatch: a single
useServerEvents()hook owns theEventSource.job.updated/task.updated→queryClient.invalidateQueries(...);task.log→ forwarded to the activeTaskDetaillog buffer. - Reconnect:
EventSourcereconnects natively.
Server-side fanout (future)¶
worker child ─▶ DB commit ─▶ feeder ─▶ in-process bus ─▶ SSE endpoint ─▶ client
▲
├── Postgres: LISTEN/NOTIFY
└── SQLite: poll every 2 s
| Backend | Feeder | Latency |
|---|---|---|
| Postgres (distributed) | LISTEN job_events |
sub-second |
| SQLite (local) | poll, diff snapshot every 2 s | up to 2 s |
Testing¶
| Layer | Tool | Where |
|---|---|---|
| Static type check | tsc --noEmit |
npm run check — CI gate for every frontend task |
| End-to-end (browser) | Playwright (Python) | test_e2e/web/test_smoke.py, pytest |
Implementation: test_e2e/web/test_smoke.py — golden-path smoke
(home load, @jobs view, URL sync); test_e2e/web/conftest.py — server
fixture (uvicorn on a free port) + Playwright fixtures (base_url,
browser, page). Playwright is an optional dep — tests skip cleanly
when the package is absent.
Why Playwright Python: an e2e test exercises the browser, FastAPI,
orchestrator, and DB together. test_e2e/web/ shares the pytest harness
with test_e2e/docker/ rather than running a parallel Node runner.
E2E suites don't run in default pytest
test_e2e/<suite>/ is excluded from the default pytest testpaths
and only runs when the path is passed explicitly or in a dedicated
CI workflow. The test-ui-e2e-dist job in
.github/workflows/_test-reusable.yaml runs test_smoke.py on every
PR against the distributed (Postgres + ClickHouse) backend.
Deferred work (SSE endpoint, cross-host logs, Vitest, OpenAPI codegen, auth)
is tracked in docs/future.md.