Skip to content

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.tsfetchJSON / 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.tsuseJobs(), 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/eventstext/event-stream
  • Client dispatch: a single useServerEvents() hook owns the EventSource. job.updated / task.updatedqueryClient.invalidateQueries(...); task.log → forwarded to the active TaskDetail log buffer.
  • Reconnect: EventSource reconnects 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.