--- phase: 26-pwa-performance plan: 03 type: execute wave: 3 depends_on: - 26-00 - 26-02 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 @.planning/phases/26-pwa-performance/26-02-SUMMARY.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 MobileBottomNav, 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 `