` — renders `ChatMessageList` (reuse existing)
+ d. Input bar: `
` — renders `ChatInput` (reuse existing)
+ - Height calculation: use `h-[100dvh]` on the outer container (NOT `100vh` — per RESEARCH Pitfall 3)
+ - Two views within MobileChatView:
+ a. When `activeConversationId` is null: show conversation list (full screen) wrapped in `PullToRefresh`
+ b. When `activeConversationId` is set: show header + message list + input
+
+2. Update `ui/src/components/ChatPanel.tsx`:
+ - Import `useMediaQuery` from `../hooks/useMediaQuery`
+ - Import `MobileChatView` from `./MobileChatView`
+ - At the top of ChatPanel function body, add: `const isDesktop = useMediaQuery("(min-width: 768px)");`
+ - Conditional render: if `!isDesktop`, render `
` passing all necessary props/context. If `isDesktop`, render existing desktop panel layout unchanged.
+ - The existing `"hidden md:flex"` class on the desktop container already hides it on mobile, but the explicit conditional ensures MobileChatView renders on mobile.
+
+3. Update `ui/src/components/ChatConversationList.tsx`:
+ - Import `PullToRefresh` from `./PullToRefresh`
+ - Import `useMediaQuery` from `../hooks/useMediaQuery`
+ - Wrap the ScrollArea content in `
` where `refetch` is from `useChatConversations` and `isMobile = !useMediaQuery("(min-width: 768px)")`
+ - Each conversation list item already has adequate height but verify `min-h-[48px]` — add it if missing per UI-SPEC touch target rule
+
+4. Update `ui/src/components/ChatInput.tsx`:
+ - Add `pb-[env(safe-area-inset-bottom)]` class to the outermost input container (only on mobile — use a conditional class or always apply since it's a no-op on desktop)
+ - Ensure the Send button has `min-h-[44px] min-w-[44px]` for touch target compliance
+
+
+ grep -q "MobileChatView" ui/src/components/ChatPanel.tsx && grep -q "100dvh" ui/src/components/MobileChatView.tsx && grep -q "PullToRefresh" ui/src/components/ChatConversationList.tsx && grep -q "safe-area-inset-bottom" ui/src/components/ChatInput.tsx && echo "PASS"
+
+
+ - `MobileChatView.tsx` exists with `100dvh` height, back button with `aria-label="Back to conversations"`, sticky input bar with `safe-area-inset-bottom`
+ - `ChatPanel.tsx` imports and conditionally renders `MobileChatView` for mobile
+ - `ChatConversationList.tsx` wraps content in `PullToRefresh` for mobile
+ - `ChatInput.tsx` has `safe-area-inset-bottom` padding
+ - Send button has minimum 44px touch target
+ - `pnpm --filter @paperclipai/ui build` succeeds
+
+ MobileChatView renders full-screen chat on mobile. ChatPanel conditionally renders mobile vs desktop. ChatConversationList has pull-to-refresh. ChatInput has safe area padding and proper touch targets.
+
+
+
+
+
+- `pnpm --filter @paperclipai/ui build` succeeds
+- MobileChatView uses `100dvh` not `100vh`
+- MobileNavBar has 44px minimum touch targets
+- PullToRefresh triggers after 64px threshold
+- ChatPanel conditionally renders MobileChatView on mobile
+- Safe area insets applied on input bar
+
+
+
+Mobile responsive layout complete. Phone users see full-screen chat, bottom nav, pull-to-refresh, and properly sized touch targets. Desktop layout unchanged.
+
+
+
diff --git a/.planning/phases/26-pwa-performance/26-03-PLAN.md b/.planning/phases/26-pwa-performance/26-03-PLAN.md
new file mode 100644
index 00000000..c979cbb8
--- /dev/null
+++ b/.planning/phases/26-pwa-performance/26-03-PLAN.md
@@ -0,0 +1,236 @@
+---
+phase: 26-pwa-performance
+plan: 03
+type: execute
+wave: 2
+depends_on:
+ - 26-00
+files_modified:
+ - ui/src/components/InstallPromptBanner.tsx
+ - ui/src/components/OfflineBanner.tsx
+ - ui/src/hooks/useInstallPrompt.ts
+ - ui/src/hooks/useOfflineQueue.ts
+ - ui/src/hooks/useOnlineStatus.ts
+ - ui/src/components/ChatPanel.tsx
+autonomous: true
+requirements:
+ - PWA-01
+ - PWA-02
+ - PWA-08
+must_haves:
+ truths:
+ - "Install prompt banner appears after beforeinstallprompt fires and user has visited a conversation"
+ - "Install prompt is dismissable and respects 7-day localStorage cooldown"
+ - "On iOS (no beforeinstallprompt), banner shows Share menu instructions"
+ - "Offline banner appears when navigator.onLine is false, showing queued message count"
+ - "Offline banner auto-dismisses 3 seconds after reconnection when queue is empty"
+ - "Unsent messages are stored in IndexedDB and flushed when online event fires"
+ artifacts:
+ - path: "ui/src/components/InstallPromptBanner.tsx"
+ provides: "PWA install prompt UI"
+ min_lines: 40
+ - path: "ui/src/components/OfflineBanner.tsx"
+ provides: "Offline status banner with queue count"
+ min_lines: 20
+ - path: "ui/src/hooks/useInstallPrompt.ts"
+ provides: "Captures beforeinstallprompt event"
+ min_lines: 20
+ - path: "ui/src/hooks/useOfflineQueue.ts"
+ provides: "IndexedDB message queue with flush on reconnect"
+ min_lines: 40
+ - path: "ui/src/hooks/useOnlineStatus.ts"
+ provides: "navigator.onLine reactive state"
+ min_lines: 10
+ key_links:
+ - from: "ui/src/hooks/useOfflineQueue.ts"
+ to: "idb"
+ via: "openDB for IndexedDB access"
+ pattern: "openDB"
+ - from: "ui/src/components/InstallPromptBanner.tsx"
+ to: "ui/src/hooks/useInstallPrompt.ts"
+ via: "canInstall + promptInstall from hook"
+ pattern: "useInstallPrompt"
+ - from: "ui/src/components/OfflineBanner.tsx"
+ to: "ui/src/hooks/useOnlineStatus.ts"
+ via: "isOnline state for show/hide"
+ pattern: "useOnlineStatus"
+---
+
+
+Create the PWA install prompt banner, offline status banner, and offline message queue. Users see an install prompt after engagement, an amber banner when offline with queued message count, and messages auto-send when back online.
+
+Purpose: Delivers PWA-01 (offline capability with message queuing), PWA-02 (installable manifest — already done, this plan adds the install UI), and PWA-08 (Add to Home Screen prompt).
+Output: 2 new components, 3 new hooks, ChatPanel integration for offline queue.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/26-pwa-performance/26-RESEARCH.md
+@.planning/phases/26-pwa-performance/26-UI-SPEC.md
+@ui/src/components/ChatPanel.tsx
+@ui/src/api/chat.ts
+
+
+From ui/src/api/chat.ts (chatApi used by offline queue):
+```typescript
+// chatApi.postMessage or equivalent POST method for sending messages
+// The offline queue needs to know the POST shape to replay messages
+```
+
+From ui/src/types/pwa.d.ts (created in Plan 00):
+```typescript
+interface BeforeInstallPromptEvent extends Event {
+ prompt(): Promise;
+ userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
+}
+```
+
+
+
+
+
+
+ Task 1: Create useInstallPrompt, useOnlineStatus, useOfflineQueue hooks
+ ui/src/hooks/useInstallPrompt.ts, ui/src/hooks/useOnlineStatus.ts, ui/src/hooks/useOfflineQueue.ts
+
+ - ui/src/types/pwa.d.ts
+ - ui/src/api/chat.ts
+ - .planning/phases/26-pwa-performance/26-RESEARCH.md
+
+
+1. Create `ui/src/hooks/useOnlineStatus.ts`:
+ - Export `function useOnlineStatus(): boolean`
+ - Use `useState(navigator.onLine)` for initial state
+ - `useEffect` adding `online` and `offline` event listeners on `window`
+ - Return `isOnline`
+
+2. Create `ui/src/hooks/useInstallPrompt.ts`:
+ - Export `function useInstallPrompt(): { canInstall: boolean; promptInstall: () => Promise; isIOS: boolean }`
+ - Use `useState(null)` for deferred prompt
+ - `useEffect` listening for `beforeinstallprompt` on `window`: call `e.preventDefault()`, store event
+ - `isInstalled`: check `window.matchMedia("(display-mode: standalone)").matches`
+ - `isIOS`: detect via `navigator.userAgent` containing "iPhone" or "iPad" and not "CriOS" (Chrome on iOS)
+ - `promptInstall`: call `deferredPrompt.prompt()`, await `deferredPrompt.userChoice`, set deferred to null
+ - `canInstall`: `!!deferredPrompt && !isInstalled`
+ - Return `{ canInstall, promptInstall, isIOS }`
+
+3. Create `ui/src/hooks/useOfflineQueue.ts`:
+ - Import `{ openDB }` from `"idb"`
+ - Constants: `DB_NAME = "nexus-offline"`, `STORE = "message_queue"`
+ - Export `function useOfflineQueue(): { enqueue: (conversationId: string, content: string) => Promise; flush: () => Promise; queuedCount: number }`
+ - Use `useState(0)` for `queuedCount`
+ - `getDb` helper: `openDB(DB_NAME, 1, { upgrade(db) { db.createObjectStore(STORE, { autoIncrement: true }); } })`
+ - `enqueue` callback: opens db, adds `{ conversationId, content, queuedAt: Date.now() }` to store, increments `queuedCount`
+ - `flush` callback: opens db, gets all entries and keys, iterates sequentially:
+ a. For each entry, call `chatApi.sendMessage(entry.conversationId, { content: entry.content })` (read chatApi to find the correct method name)
+ b. On success, delete the key from the store, decrement `queuedCount`
+ c. On failure, `break` — stop flushing, retry next time
+ - `useEffect` listening for `online` event on `window` — calls `flush()`
+ - `useEffect` on mount — reads current queue count from IndexedDB and sets `queuedCount`
+ - Return `{ enqueue, flush, queuedCount }`
+
+
+ grep -q "openDB" ui/src/hooks/useOfflineQueue.ts && grep -q "beforeinstallprompt" ui/src/hooks/useInstallPrompt.ts && grep -q "navigator.onLine" ui/src/hooks/useOnlineStatus.ts && echo "PASS"
+
+
+ - `useOnlineStatus.ts` returns boolean based on `navigator.onLine`
+ - `useInstallPrompt.ts` captures `beforeinstallprompt` event and returns `canInstall`, `promptInstall`, `isIOS`
+ - `useOfflineQueue.ts` uses `idb` library's `openDB`, stores to `nexus-offline` DB, `message_queue` store
+ - `useOfflineQueue.ts` flushes on `online` event, stops on first failure
+ - `pnpm --filter @paperclipai/ui build` succeeds
+
+ Three hooks created: useOnlineStatus (reactive online/offline state), useInstallPrompt (beforeinstallprompt capture with iOS detection), useOfflineQueue (IndexedDB queue with auto-flush).
+
+
+
+ Task 2: Create InstallPromptBanner, OfflineBanner, and wire into ChatPanel
+ ui/src/components/InstallPromptBanner.tsx, ui/src/components/OfflineBanner.tsx, ui/src/components/ChatPanel.tsx
+
+ - ui/src/hooks/useInstallPrompt.ts
+ - ui/src/hooks/useOnlineStatus.ts
+ - ui/src/hooks/useOfflineQueue.ts
+ - ui/src/components/ChatPanel.tsx
+ - .planning/phases/26-pwa-performance/26-UI-SPEC.md
+
+
+1. Create `ui/src/components/InstallPromptBanner.tsx`:
+ - Import `useInstallPrompt` hook
+ - Props: none (self-contained)
+ - Internal state: `dismissed` from `localStorage.getItem("nexus.installPromptDismissed")`
+ - Show conditions (ALL must be true):
+ a. `canInstall === true` OR `isIOS === true` (iOS gets instruction text)
+ b. Not already installed (`display-mode: standalone` check is in the hook)
+ c. Not dismissed within last 7 days (check `nexus.installPromptDismissed` timestamp in localStorage)
+ d. User has visited at least one conversation (pass `hasEngaged` as prop or check localStorage)
+ - Layout (per UI-SPEC):
+ a. Fixed position: `fixed bottom-16 left-4 right-4 md:bottom-auto md:top-4 md:left-auto md:right-4 md:max-w-sm` (bottom on mobile above MobileNavBar, top-right on desktop)
+ b. Background: `bg-card border border-border rounded-lg shadow-lg p-4`
+ c. Heading: "Add Nexus to your home screen" in `text-sm font-semibold`
+ d. Body: "Get the full experience — launch instantly, works offline." in `text-xs text-muted-foreground`
+ e. For iOS: body text changes to "Open the Share menu and tap 'Add to Home Screen'"
+ f. CTA button: `` — calls `promptInstall()` (or no-op on iOS)
+ g. Dismiss button: "Not now" as `
+
+ grep -q "nexus.installPromptDismissed" ui/src/components/InstallPromptBanner.tsx && grep -q "amber" ui/src/components/OfflineBanner.tsx && grep -q "enqueue" ui/src/components/ChatPanel.tsx && echo "PASS"
+
+
+ - `InstallPromptBanner.tsx` shows "Add Nexus to your home screen" heading, "Add to Home Screen" CTA, "Not now" dismiss
+ - `InstallPromptBanner.tsx` checks `nexus.installPromptDismissed` localStorage with 7-day expiry
+ - `InstallPromptBanner.tsx` handles iOS with Share menu instruction text
+ - `OfflineBanner.tsx` uses amber styling matching UI-SPEC (dark: `bg-amber-900/40`, light: `bg-amber-50`)
+ - `OfflineBanner.tsx` displays queued count when `queuedCount > 0`
+ - `ChatPanel.tsx` calls `enqueue` when offline instead of sending
+ - `pnpm --filter @paperclipai/ui build` succeeds
+
+ InstallPromptBanner shows install CTA with iOS fallback and 7-day dismiss cooldown. OfflineBanner shows amber notification with queue count. ChatPanel queues messages when offline via useOfflineQueue.
+
+
+
+
+
+- `pnpm --filter @paperclipai/ui build` succeeds
+- InstallPromptBanner respects 7-day localStorage dismiss cooldown
+- OfflineBanner uses correct amber styling for dark/light themes
+- ChatPanel enqueues messages to IndexedDB when offline
+- useOfflineQueue auto-flushes on `online` event
+
+
+
+PWA install prompt, offline banner, and offline message queue all functional. Users see install prompt after engagement, see offline status with queue count, and messages auto-send on reconnection.
+
+
+
diff --git a/.planning/phases/26-pwa-performance/26-04-PLAN.md b/.planning/phases/26-pwa-performance/26-04-PLAN.md
new file mode 100644
index 00000000..56d97f48
--- /dev/null
+++ b/.planning/phases/26-pwa-performance/26-04-PLAN.md
@@ -0,0 +1,272 @@
+---
+phase: 26-pwa-performance
+plan: 04
+type: execute
+wave: 3
+depends_on:
+ - 26-00
+ - 26-02
+ - 26-03
+files_modified:
+ - packages/db/src/schema/push_subscriptions.ts
+ - packages/db/src/schema/index.ts
+ - server/src/services/pushService.ts
+ - server/src/routes/push.ts
+ - server/src/app.ts
+ - ui/src/hooks/usePushNotifications.ts
+ - ui/src/api/push.ts
+ - ui/src/components/NotificationPermissionPrompt.tsx
+ - ui/src/components/ChatPanel.tsx
+autonomous: true
+requirements:
+ - PWA-06
+must_haves:
+ truths:
+ - "Server exposes VAPID public key via GET /api/push/vapid-public-key"
+ - "Client can subscribe to push notifications via POST /api/push/subscribe"
+ - "Client can unsubscribe via DELETE /api/push/subscribe"
+ - "Server can send push notifications to all subscriptions for a given company"
+ - "Permission prompt appears after third agent response, not on first load"
+ - "Push subscriptions are stored in libSQL push_subscriptions table"
+ - "Stale subscriptions (410 Gone) are auto-deleted on send failure"
+ artifacts:
+ - path: "packages/db/src/schema/push_subscriptions.ts"
+ provides: "Push subscription DB table"
+ contains: "pushSubscriptions"
+ - path: "server/src/services/pushService.ts"
+ provides: "VAPID config + sendPush helper"
+ contains: "sendNotification"
+ - path: "server/src/routes/push.ts"
+ provides: "Push API routes"
+ contains: "vapid-public-key"
+ - path: "ui/src/hooks/usePushNotifications.ts"
+ provides: "Push subscription management hook"
+ contains: "pushManager.subscribe"
+ - path: "ui/src/components/NotificationPermissionPrompt.tsx"
+ provides: "Permission request UI"
+ contains: "Stay in the loop"
+ key_links:
+ - from: "ui/src/hooks/usePushNotifications.ts"
+ to: "server/src/routes/push.ts"
+ via: "POST /api/push/subscribe with subscription JSON"
+ pattern: "/api/push/subscribe"
+ - from: "server/src/services/pushService.ts"
+ to: "web-push"
+ via: "webPush.sendNotification()"
+ pattern: "sendNotification"
+ - from: "server/src/routes/push.ts"
+ to: "packages/db/src/schema/push_subscriptions.ts"
+ via: "Drizzle insert/delete on push_subscriptions table"
+ pattern: "pushSubscriptions"
+---
+
+
+Wire push notifications end-to-end: DB schema for subscriptions, server VAPID config + push routes, client subscription hook, and notification permission prompt UI.
+
+Purpose: Delivers PWA-06 (push notifications for agent mentions, task completions, and handoff requests). Server stores subscriptions and sends notifications. Client requests permission after user engagement.
+Output: 1 new DB table, 1 server service, 1 server route file, 1 client API module, 1 hook, 1 component.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/26-pwa-performance/26-RESEARCH.md
+@.planning/phases/26-pwa-performance/26-UI-SPEC.md
+@packages/db/src/schema/index.ts
+@server/src/app.ts
+@server/src/routes/chat.ts
+
+
+From packages/db/src/schema (Drizzle pattern):
+```typescript
+// All tables use: sqliteTable(), text(), integer(), index()
+// UUID ids via text("id").primaryKey()
+// Timestamps via integer("created_at", { mode: "timestamp" })
+// Index callbacks use object-syntax: (table) => ({})
+```
+
+From server/src/app.ts (route mounting):
+```typescript
+// Routes mounted via: app.use("/api/push", pushRoutes(db));
+// Pattern matches: chatRoutes(db), fileRoutes(db), etc.
+```
+
+From ui/public/sw.js (created in Plan 00):
+```javascript
+// SW handles 'push' event and shows notification
+// SW handles 'notificationclick' event and opens URL
+```
+
+
+
+
+
+
+ Task 1: Create push_subscriptions DB schema, pushService, and push routes
+ packages/db/src/schema/push_subscriptions.ts, packages/db/src/schema/index.ts, server/src/services/pushService.ts, server/src/routes/push.ts, server/src/app.ts
+
+ - packages/db/src/schema/chat_conversations.ts
+ - packages/db/src/schema/index.ts
+ - server/src/routes/chat.ts
+ - server/src/app.ts
+ - .planning/phases/26-pwa-performance/26-RESEARCH.md
+
+
+1. Create `packages/db/src/schema/push_subscriptions.ts`:
+ - Import from `drizzle-orm/sqlite-core`: `sqliteTable`, `text`, `integer`, `index`
+ - Define `pushSubscriptions` table with columns:
+ a. `id` — `text("id").primaryKey()` (UUID, generated at insert time)
+ b. `endpoint` — `text("endpoint").notNull()` (push service endpoint URL)
+ c. `p256dh` — `text("p256dh").notNull()` (client public key)
+ d. `auth` — `text("auth").notNull()` (client auth secret)
+ e. `userId` — `text("user_id")` (nullable — local mode may not have auth)
+ f. `companyId` — `text("company_id")` (nullable — for scoping notifications)
+ g. `deviceLabel` — `text("device_label")` (optional user-agent or device name)
+ h. `createdAt` — `integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date())`
+ - Index: `(table) => ({ endpointIdx: index("push_sub_endpoint_idx").on(table.endpoint) })`
+ - Use the `(table) => ({})` object-syntax for index callbacks (matches codebase convention)
+
+2. Update `packages/db/src/schema/index.ts`:
+ - Add `export * from "./push_subscriptions";`
+
+3. Create `server/src/services/pushService.ts`:
+ - Import `webPush` from `"web-push"`
+ - Import `eq` from `"drizzle-orm"`
+ - Import `pushSubscriptions` from the schema
+ - Type `db` parameter as the Drizzle database type used across the codebase (check chat.ts for the pattern)
+ - `initVapid()` function: call `webPush.setVapidDetails(process.env.VAPID_SUBJECT || "mailto:admin@nexus.local", process.env.VAPID_PUBLIC_KEY!, process.env.VAPID_PRIVATE_KEY!)` — call at service creation time, only if VAPID_PUBLIC_KEY is set (graceful skip if not configured)
+ - `getVapidPublicKey()`: returns `process.env.VAPID_PUBLIC_KEY || null`
+ - `saveSubscription(db, { endpoint, p256dh, auth, userId?, companyId?, deviceLabel? })`: generate UUID, insert into `pushSubscriptions`, return the id
+ - `removeSubscription(db, endpoint)`: delete from `pushSubscriptions` where `endpoint` matches
+ - `sendPushToAll(db, companyId, payload: { title: string; body: string; icon?: string; data?: Record })`:
+ a. Query all subscriptions (filter by companyId if provided, otherwise all)
+ b. For each subscription, call `webPush.sendNotification({ endpoint, keys: { p256dh, auth } }, JSON.stringify(payload))`
+ c. On `410 Gone` or `404 Not Found` response, delete the stale subscription from DB (per RESEARCH Pitfall 6)
+ d. Log errors but don't throw — push is best-effort
+ - Export the functions (not a class — matches codebase service pattern in chat.ts)
+
+4. Create `server/src/routes/push.ts`:
+ - Export `function pushRoutes(db: ...)` returning an Express Router (match pattern in chat.ts)
+ - `GET /vapid-public-key` — returns `{ publicKey: getVapidPublicKey() }` or 404 if not configured
+ - `POST /subscribe` — body: `{ endpoint, keys: { p256dh, auth }, userId?, companyId?, deviceLabel? }` — calls `saveSubscription`, returns 201
+ - `DELETE /subscribe` — body: `{ endpoint }` — calls `removeSubscription`, returns 204
+
+5. Update `server/src/app.ts`:
+ - Import `pushRoutes` from `"./routes/push"`
+ - Import `initVapid` from `"./services/pushService"`
+ - Mount: `app.use("/api/push", pushRoutes(db));`
+ - Call `initVapid()` at app startup (after db init, before listen) — wrap in try/catch, log warning if VAPID keys not configured
+
+
+ grep -q "pushSubscriptions" packages/db/src/schema/push_subscriptions.ts && grep -q "push_subscriptions" packages/db/src/schema/index.ts && grep -q "sendNotification" server/src/services/pushService.ts && grep -q "vapid-public-key" server/src/routes/push.ts && grep -q "pushRoutes" server/src/app.ts && echo "PASS"
+
+
+ - `push_subscriptions.ts` schema has `id`, `endpoint`, `p256dh`, `auth`, `userId`, `companyId`, `createdAt` columns
+ - `push_subscriptions.ts` uses object-syntax `(table) => ({})` for index callback
+ - `schema/index.ts` exports push_subscriptions
+ - `pushService.ts` initializes VAPID only if env vars are set (graceful skip)
+ - `pushService.ts` deletes stale subscriptions on 410/404 response
+ - `push.ts` routes: GET `/vapid-public-key`, POST `/subscribe`, DELETE `/subscribe`
+ - `app.ts` mounts push routes and calls `initVapid()`
+ - `pnpm --filter @paperclipai/server build` succeeds (if build script exists) or TypeScript compiles
+
+ Push notification backend complete: DB table, VAPID service, API routes, app mounting. Stale subscriptions auto-cleaned.
+
+
+
+ Task 2: Create push API client, usePushNotifications hook, and NotificationPermissionPrompt
+ ui/src/api/push.ts, ui/src/hooks/usePushNotifications.ts, ui/src/components/NotificationPermissionPrompt.tsx, ui/src/components/ChatPanel.tsx
+
+ - ui/src/api/chat.ts
+ - ui/src/hooks/useInstallPrompt.ts
+ - ui/src/components/ChatPanel.tsx
+ - .planning/phases/26-pwa-performance/26-UI-SPEC.md
+ - .planning/phases/26-pwa-performance/26-RESEARCH.md
+
+
+1. Create `ui/src/api/push.ts`:
+ - Export `pushApi` object with:
+ a. `getVapidPublicKey(): Promise<{ publicKey: string | null }>` — GET `/api/push/vapid-public-key`
+ b. `subscribe(subscription: PushSubscriptionJSON, meta?: { userId?: string; companyId?: string; deviceLabel?: string }): Promise` — POST `/api/push/subscribe` with `{ ...subscription.toJSON(), ...meta }`
+ c. `unsubscribe(endpoint: string): Promise` — DELETE `/api/push/subscribe` with `{ endpoint }`
+ - Follow the same fetch pattern used in `chat.ts` (read it for the pattern — likely uses fetch with JSON headers)
+
+2. Create `ui/src/hooks/usePushNotifications.ts`:
+ - Export `function usePushNotifications(): { isSupported: boolean; permission: NotificationPermission | "unsupported"; subscribe: () => Promise; unsubscribe: () => Promise }`
+ - `isSupported`: `"serviceWorker" in navigator && "PushManager" in window && "Notification" in window`
+ - `permission`: `useState(Notification.permission ?? "default")` — poll via `useEffect` if permission might change
+ - `subscribe` callback:
+ a. Request permission: `const perm = await Notification.requestPermission()` — update state
+ b. If `perm !== "granted"`, return early
+ c. Get VAPID public key: `const { publicKey } = await pushApi.getVapidPublicKey()` — if null, return early
+ d. Get SW registration: `const reg = await navigator.serviceWorker.ready`
+ e. Subscribe: `const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) })`
+ f. Send to server: `await pushApi.subscribe(sub.toJSON())`
+ - `unsubscribe` callback:
+ a. Get SW registration and existing subscription
+ b. Call `sub.unsubscribe()`
+ c. Call `pushApi.unsubscribe(sub.endpoint)`
+ - Include `urlBase64ToUint8Array` utility function (per RESEARCH code example — converts base64 VAPID key to Uint8Array)
+
+3. Create `ui/src/components/NotificationPermissionPrompt.tsx`:
+ - Import `usePushNotifications` hook
+ - Internal state: `dismissed` (from `localStorage.getItem("nexus.notifPromptDismissed")`)
+ - Props: `{ agentResponseCount: number }` (engagement gate — show after 3rd agent response)
+ - Show conditions (ALL must be true):
+ a. `isSupported === true`
+ b. `permission === "default"` (not already granted or denied)
+ c. `!dismissed` (not previously dismissed — stored in `nexus.notifPromptDismissed` localStorage key)
+ d. `agentResponseCount >= 3` (engagement gate per UI-SPEC)
+ - Layout:
+ a. Container: `fixed bottom-20 left-4 right-4 md:bottom-auto md:top-16 md:left-auto md:right-4 md:max-w-sm z-50 bg-card border border-border rounded-lg shadow-lg p-4`
+ b. Heading: "Stay in the loop" in `text-sm font-semibold`
+ c. Body: "Get notified when your agents complete tasks or need input." in `text-xs text-muted-foreground mt-1`
+ d. Buttons row: `flex gap-2 mt-3`
+ - "Allow notifications" — `
+
+ grep -q "pushManager.subscribe" ui/src/hooks/usePushNotifications.ts && grep -q "Stay in the loop" ui/src/components/NotificationPermissionPrompt.tsx && grep -q "NotificationPermissionPrompt" ui/src/components/ChatPanel.tsx && grep -q "vapid-public-key" ui/src/api/push.ts && echo "PASS"
+
+
+ - `push.ts` API client has `getVapidPublicKey`, `subscribe`, `unsubscribe` methods
+ - `usePushNotifications.ts` checks `isSupported`, requests permission, subscribes via SW pushManager
+ - `usePushNotifications.ts` includes `urlBase64ToUint8Array` utility
+ - `NotificationPermissionPrompt.tsx` shows after 3rd agent response (`agentResponseCount >= 3`)
+ - `NotificationPermissionPrompt.tsx` checks `nexus.notifPromptDismissed` localStorage
+ - `NotificationPermissionPrompt.tsx` has "Stay in the loop" heading and "Allow notifications" / "Not now" buttons
+ - `ChatPanel.tsx` renders the prompt with correct `agentResponseCount`
+ - `pnpm --filter @paperclipai/ui build` succeeds
+
+ Push notification system complete end-to-end. Client subscribes via SW pushManager, server stores subscriptions, NotificationPermissionPrompt appears after engagement gate.
+
+
+
+
+
+- `pnpm --filter @paperclipai/ui build` succeeds
+- Push subscription DB table defined with proper columns
+- Server VAPID init is graceful (no crash if env vars missing)
+- Push routes mounted at `/api/push`
+- Client hook subscribes via SW pushManager
+- Permission prompt respects engagement gate and localStorage dismiss
+
+
+
+Push notifications wired end-to-end. Server stores subscriptions, sends via web-push, auto-cleans stale 410 endpoints. Client subscribes after permission grant. Prompt appears after 3 agent responses. System degrades gracefully when VAPID keys not configured.
+
+
+