Frontend Architecture
---

Technical spec for the aaiclick web UI: framework, build, data layer, and
real-time updates. UX (layout, modes, wireframes) lives in `docs/designs/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
    schema.ts                   ← generated from the OpenAPI schema (do not edit)
    types.ts                    ← ergonomic re-exports over schema.ts
    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.

!!! warning "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**: `npm run gen-types` generates `src/api/schema.ts` from the server's OpenAPI
  schema (`app.openapi()` → `openapi-typescript`); `src/api/types.ts` re-exports ergonomic names
  (`JobView`, `TaskDetail`, `Page<T>`, …) over it. CI fails on drift, so types always match the
  server — never hand-edit `schema.ts`.
- **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`).

## API types

Two files, created two different ways:

- **`src/api/schema.ts`** — generated, never hand-edited. `npm run gen-types`
  pipes the server's schema (`python -m aaiclick.server.dump_openapi`, which
  prints `app.openapi()`) through `openapi-typescript`. It mirrors the *entire*
  API — every path, operation, and model. Treat it like a lockfile: machine-
  owned, not meant to be read.
- **`src/api/types.ts`** — hand-written, ~30 lines. It holds no field
  definitions; it re-exports the handful of models the SPA uses under ergonomic
  names, borrowing their shapes from `schema.ts`:

```ts
import type { components } from "./schema";
type S = components["schemas"];
export type JobView = S["JobView"];
export type TaskLogs = S["TaskLogsView"];   // bridge a server name diff
```

The real shapes have to live somewhere; generating them keeps `types.ts` in
lock-step with the server (it used to be hand-typed and drifted silently).
Generated types are erased at build time, so `schema.ts`'s size costs nothing
in the bundle. The shim also absorbs what codegen can't express: the `TaskLogs
→ TaskLogsView` rename and the `Page<T>` generic (FastAPI emits a concrete
`Page_JobView_`, … per instantiation).

When the server adds a model, run `npm run gen-types` and add one re-export
line. **CI runs `gen-types` and fails on any diff**, so the types can't fall
behind the server.

# 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/designs/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 the `EventSource`.
  `job.updated` / `task.updated` → `queryClient.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.

!!! warning "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/designs/future.md`.
