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                  | Utility-first, no design-system overhead         |
| Build        | Vite                         | Fast HMR, native ESM, zero-config TS             |
| Data fetch   | TanStack Query (React Query) | Caching, retries, stale-while-revalidate         |
| Real-time    | Server-Sent Events (SSE)     | One-way, native reconnect, simpler than WS       |
| 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
  api/                          ← typed REST client + React Query hooks
  views/                        ← one component per UI mode (home, jobs, ...)
  components/                   ← shared UI (badges, tables, prompt input)
  styles/                       ← Tailwind entry, globals

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 source of truth. SSE events are signals to invalidate caches —
not the data itself.

- **Typed REST client** in `src/api/` — one function per endpoint, return
  types derived from the OpenAPI schema (or hand-written for v0).
- **React Query hooks** wrap each endpoint: `useJobs()`, `useJob(id)`,
  `useTask(id)`. Hooks own staleness and refetch policy.
- **No global store**. The URL (driven by the prompt) selects which view
  mounts; React Query owns server state; component state owns UI ephemera
  (input focus, scroll position).

```ts
// src/api/jobs.ts
export function useJobs() {
  return useQuery({
    queryKey: ['jobs'],
    queryFn: () => fetchJSON<Job[]>('/api/v0/jobs'),
    staleTime: 5_000,
  });
}
```

# Real-time events

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`
- **Event format**: native SSE `event:` field for typing
  ```
  event: job.updated
  data: {"id": "abc123"}

  event: task.updated
  data: {"id": 42, "job_id": "abc123"}

  event: task.log
  data: {"task_id": 42, "line": "...", "ts": "..."}
  ```
- **Client dispatch**: a single `useServerEvents()` hook owns the
  `EventSource`. Per event name:
  - `job.updated` / `task.updated` → `queryClient.invalidateQueries(...)`
  - `task.log` → forwarded to the active `TaskDetail` log buffer
- **Reconnect**: `EventSource` reconnects natively. Server uses
  `Last-Event-ID` for resume; for v0, send a fresh snapshot on connect.
- **Backpressure**: server coalesces high-frequency events (logs) into
  bounded batches per flush.

!!! tip "Events are signals, not state"
    If reconnect drops events, the next REST refetch heals the cache.
    No event-replay protocol needed.

# Server-side fanout

SSE is just the wire format. Workers update job/task state in **child
processes** (`mp_worker`, `docker_worker`); the FastAPI process serving
SSE clients does not see those updates directly. The bridge is the
database. Two feeder mechanisms — selected at startup based on the SQL
backend — push deltas onto a shared in-process bus, which the SSE
endpoint subscribes to.

```
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   |

**Postgres feeder**: orchestration code that updates `jobs` / `tasks`
emits `NOTIFY job_events, '<json>'` in the same transaction as the
status update. The FastAPI process holds one `LISTEN` connection per
backend instance and forwards each notification onto the in-process bus.
A single SQL channel carries all event types — the JSON payload's
discriminator picks the SSE event name.

**SQLite feeder**: same shape, polled. A background task reads the
recent rows from `jobs` / `tasks` (and the log table for `task.log`)
every 2 s, diffs against the previous snapshot, pushes deltas onto the
bus.

!!! warning "NOTIFY must be wired into every status write"
    Every code path that mutates job/task status owes a `NOTIFY` in the
    same commit. Missing one means a stuck UI in distributed mode.
    Centralize it in the orchestration write helpers, not at call sites.

Redis Pub/Sub is the next upgrade — only needed once we run multiple
FastAPI workers across machines and need cross-host fanout that
LISTEN/NOTIFY can't cheaply provide. Tracked in `docs/future.md`.

# Testing

The SPA is exercised end-to-end through the deployed stack — the same
strategy the rest of aaiclick uses for system-level tests.

| Layer                | Tool                  | Where                              |
|----------------------|-----------------------|------------------------------------|
| Static type check    | `tsc --noEmit`        | `npm run check`, CI gate           |
| End-to-end (browser) | Playwright (Python)   | `test_e2e/web/`, pytest            |

**Why Playwright Python**: an e2e test isn't a frontend test — it drives
the browser, the FastAPI server, the orchestrator, and the DB together.
Living in `test_e2e/web/` lets it share pytest fixtures with the existing
Docker e2e suite (`test_e2e/docker/`) and run on the same dedicated
workflow rather than spinning up a parallel Node runner.

**Frontend unit tests** (Vitest + React Testing Library) are deferred —
see `docs/future.md`. For v0, the static type check plus e2e coverage is
the only gate. Add unit tests when the component layer grows enough that
e2e feedback gets too coarse.

!!! warning "E2E suites don't run in default `pytest`"
    Per `CLAUDE.md`, `test_e2e/<suite>/` is excluded from the default
    `pytest` invocation and only runs in dedicated workflows.

# Open questions

Tracked in `docs/future.md`:

- Auth model for the SSE endpoint (cookie vs. token)
- OpenAPI codegen vs. hand-written types
- Per-resource SSE topics if the single-stream model gets noisy
