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.
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.
webclient/public/sw.js)Hand-rolled (~150 lines), no workbox, no build-time precache manifest.
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./api/, /ws/, /auth/, /push/, /content/, /content-viewers/, /debug, /admin, non-GET, cross-origin.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.
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.
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.
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.
NAVI_VAPID_PUBLIC_KEY / NAVI_VAPID_PRIVATE_KEY / NAVI_VAPID_SUBJECT / NAVI_PUSH_COOLDOWN_SEC — see 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).
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.)
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.