# PWA: install, offline shell, web push

The webclient is a full Progressive Web App: it installs as an app on
phones/desktops, opens from a service-worker cache when the network is down,
and sends web-push notifications when Navi answers while no tab is watching.

## Serving routes

`navi/main.py` (all inside the `NAVI_WEBCLIENT_ENABLED` gate):

| Route | Source | Cache-Control |
|---|---|---|
| `/` | `webclient/dist/index.html` | `no-store` |
| `/manifest.webmanifest` | `webclient/dist/manifest.webmanifest` | `no-cache` |
| `/sw.js` | `webclient/dist/sw.js` | `no-store` (mandatory — see versioning) |
| `/assets/*`, `/images/*` | StaticFiles mounts (unchanged) | default |

There is intentionally **no SPA fallback and no dist-root mount** — mounting
`dist/` at `/` would shadow the API routes.

## Service worker (`webclient/public/sw.js`)

Hand-rolled (~150 lines), no workbox, no build-time precache manifest.

- **Navigation requests — network-first** (3 s race) with a cached-shell
  fallback and a background refresh. Deliberately not cache-first: after a
  deploy `emptyOutDir` deletes old hashed assets, and a stale cached shell
  would 404 on its entry chunks.
- **`/assets/*` — cache-first**: Vite content-hashes filenames, so cached
  entries are immutable.
- **`/images/*` — cache-first**, capped at 200 entries.
- **Pass-through** (never cached): `/api/`, `/ws/`, `/auth/`, `/push/`,
  `/content/`, `/content-viewers/`, `/debug`, `/admin`, non-GET, cross-origin.

### Build versioning

`vite.config.js` ships a tiny inline plugin (`stamp-sw-version`) that, in
`closeBundle()`, hashes `dist/index.html` + the hashed `dist/assets/*`
filenames into a 16-hex digest and rewrites `__NAVI_BUILD_VERSION__` inside
`dist/sw.js`. Because sw.js bytes change on every build and it is served
`no-store`, the browser re-fetches it after each deploy; the new SW's
`activate` evicts every `navi-*` cache from previous builds.

Registration is PROD-only (`webclient/src/main.js`) — dev never registers, so
HMR is untouched.

## Installability

`public/manifest.webmanifest` (copied verbatim into `dist/`): standalone
display, theme/background `#16161E`, icons generated from `logo.svg`
(`icon-{192,512}.png` regular + `icon-maskable-{192,512}.png`, committed under
`webclient/public/images/` — served by the existing `/images` mount).
`index.html` carries the manifest link, `theme-color`, and
`apple-touch-icon`.

## Web push

Backend: `navi/push/` (`store.py` asyncpg store, `service.py` fan-out),
routes in `navi/api/routes/push.py`:

| Route | Purpose |
|---|---|
| `GET /push/vapid-key` | public VAPID key for `pushManager.subscribe` (503 when push is not configured) |
| `POST /push/subscribe` | upsert `{endpoint, keys:{p256dh, auth}, user_agent}` for the current user |
| `DELETE /push/subscribe` | remove by endpoint (idempotent) |

Subscriptions live in the `push_subscriptions` postgres table (created at
startup next to the auth tables). Dead endpoints (HTTP 404/410 from the push
service) are pruned on the next send.

### Trigger rule

`AgentSessionOrchestrator.run_agent` (and `run_recall`) fire a push when a
turn completes (`StreamEnd` content non-empty) **and no WebSocket client is
attached to that session** — i.e. no tab is actively watching. One turn = one
push (multi-tool turns yield a single `StreamEnd`); a per-session cooldown
(`NAVI_PUSH_COOLDOWN_SEC`, default 30 s) guards overlapping recall + agent
runs. Push is fully asynchronous (`create_task`, sends off-thread) and wrapped
in try/except — it can never disturb the run. Sessions without a user
(`user_id is None`) only push in auth-disabled deployments (the `anonymous`
admin); otherwise they are skipped.

Payload: `{"title": "Navi ответила", "body": ≤140-char markdown-stripped
preview, "url": "/#<session_id>", "session_id": ...}`. The SW's
`notificationclick` focuses an existing window and navigates it to
`/#<session_id>` (hash routing opens the right chat), or opens a new one.

### Configuration

`NAVI_VAPID_PUBLIC_KEY` / `NAVI_VAPID_PRIVATE_KEY` / `NAVI_VAPID_SUBJECT` /
`NAVI_PUSH_COOLDOWN_SEC` — see [`config.md`](config.md). Generate the VAPID
pair once with `pip install py_vapid && python -m py_vapid gen`. Empty keys =
push disabled everywhere (client panel shows the toggle as unavailable).

Client side: settings → "Notifications" toggle
(`webclient/src/components/settings/NotificationSettingsPanel.vue`,
composable `webclient/src/composables/usePush.js`).

## Offline indicator

`useOnline` composable + `OfflineBanner.vue` — an orange banner over the app
shell when `navigator.onLine` goes false. (Server outages are already
signalled by the WS reconnecting state.)

## Security note

Service workers and push require a **secure context** (HTTPS or localhost).
Navi is expected to sit behind TLS in any deployment where the PWA is used.
The httpOnly session cookie rides along automatically on push subscriptions —
the SW never touches credentials.