docs(26): create phase plan — 5 plans across 3 waves

This commit is contained in:
Nexus Dev 2026-04-02 01:44:49 +00:00
parent 9583edd3c4
commit 5f33a414e9
6 changed files with 1163 additions and 2 deletions

View file

@ -142,7 +142,15 @@ Plans:
4. On a phone, the input bar is sticky at the bottom of the screen, touch targets are large enough to tap without errors, and the layout resizes correctly when the software keyboard appears
5. Pulling down on the conversation list on mobile triggers a refresh; push notifications arrive for agent mentions, task completions, and handoff requests where the platform supports them
6. The initial page load on broadband completes in under 2 seconds and on a 3G connection in under 5 seconds; PWA cached load completes in under 1 second
**Plans:** [To be planned]
**Plans:** 5 plans
Plans:
- [ ] 26-00-PLAN.md — Foundation: SW rewrite (cache-first), deps (idb, web-push), PWA types, Wave 0 test stubs
- [ ] 26-01-PLAN.md — Performance: React.lazy route splitting + Vite vendor chunk splitting
- [ ] 26-02-PLAN.md — Mobile responsive: MobileChatView, MobileNavBar, PullToRefresh, ChatPanel/ChatInput mobile wiring
- [ ] 26-03-PLAN.md — PWA features: InstallPromptBanner, OfflineBanner, useOfflineQueue (IndexedDB message queue)
- [ ] 26-04-PLAN.md — Push notifications: DB schema, server VAPID/routes, client subscription hook, permission prompt
**UI hint**: yes
---
@ -228,4 +236,4 @@ All 65 v1 requirements are mapped to exactly one phase. No orphans.
| 23. Brainstormer Flow | v1.3 | 4/4 | Complete | 2026-04-01 |
| 24. Search, History & Branching | v1.3 | 4/4 | Complete | 2026-04-01 |
| 25. File System | v1.3 | 9/9 | Complete | 2026-04-02 |
| 26. PWA & Performance | v1.3 | 0/? | Not started | - |
| 26. PWA & Performance | v1.3 | 0/5 | Not started | - |

View file

@ -0,0 +1,196 @@
---
phase: 26-pwa-performance
plan: 00
type: execute
wave: 1
depends_on: []
files_modified:
- ui/public/sw.js
- ui/src/types/pwa.d.ts
- ui/src/hooks/useOfflineQueue.test.ts
- ui/src/hooks/useInstallPrompt.test.ts
- ui/src/hooks/usePushNotifications.test.ts
- ui/src/components/PullToRefresh.test.tsx
- ui/src/components/MobileNavBar.test.tsx
autonomous: true
requirements:
- PWA-01
- PWA-07
- PERF-05
must_haves:
truths:
- "Service worker uses cache-first for static assets and cache-first for navigation"
- "Old paperclip-v2 cache is deleted on SW activation"
- "Service worker handles push events and notificationclick events"
- "PWA TypeScript types exist for BeforeInstallPromptEvent"
- "Wave 0 test stubs exist for all hooks and components planned in later waves"
artifacts:
- path: "ui/public/sw.js"
provides: "Cache-first service worker with push handler"
contains: "nexus-v1"
- path: "ui/src/types/pwa.d.ts"
provides: "BeforeInstallPromptEvent type declaration"
contains: "BeforeInstallPromptEvent"
- path: "ui/src/hooks/useOfflineQueue.test.ts"
provides: "Test stub for offline queue hook"
contains: "it.todo"
- path: "ui/src/hooks/useInstallPrompt.test.ts"
provides: "Test stub for install prompt hook"
contains: "it.todo"
key_links:
- from: "ui/public/sw.js"
to: "ui/src/main.tsx"
via: "SW registration on load event (already wired)"
pattern: "serviceWorker\\.register"
---
<objective>
Foundation for Phase 26: rewrite the service worker from network-first to cache-first, install dependencies (idb, web-push), create PWA TypeScript types, and scaffold Wave 0 test stubs for all hooks/components coming in later plans.
Purpose: Provides the upgraded SW that enables PERF-05 (cached load < 1s) and the test infrastructure for TDD in subsequent plans.
Output: Upgraded sw.js, pwa.d.ts type declarations, 5 test stub files, idb + web-push installed.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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/public/sw.js
@ui/src/main.tsx
</context>
<tasks>
<task type="auto">
<name>Task 1: Install dependencies, create PWA types, and rewrite service worker</name>
<files>ui/public/sw.js, ui/src/types/pwa.d.ts</files>
<read_first>
- ui/public/sw.js
- ui/src/main.tsx
- ui/public/site.webmanifest
- .planning/phases/26-pwa-performance/26-RESEARCH.md
</read_first>
<action>
1. Install dependencies:
- `pnpm --filter @paperclipai/ui add idb`
- `pnpm --filter @paperclipai/server add web-push`
- `pnpm --filter @paperclipai/server add --save-dev @types/web-push`
2. Create `ui/src/types/pwa.d.ts` with:
- `BeforeInstallPromptEvent` interface extending `Event` with `prompt(): Promise<void>` and `userChoice: Promise<{ outcome: "accepted" | "dismissed" }>` properties
- Global `WindowEventMap` augmentation adding `beforeinstallprompt: BeforeInstallPromptEvent`
3. Rewrite `ui/public/sw.js` — replace the entire file with a cache-first strategy:
- Cache name: `"nexus-v1"` (replaces `"paperclip-v2"`)
- `install` event: call `self.skipWaiting()`, pre-cache `["/", "/index.html"]` into `nexus-v1`
- `activate` event: delete ALL caches whose name is NOT `"nexus-v1"` (this busts the old `paperclip-v2` cache), then call `self.clients.claim()`
- `fetch` event handler:
a. If `url.pathname.startsWith("/api")` — return immediately (network-only, no interception)
b. If `request.mode === "navigate"` — cache-first: try `caches.match("/")`, fall back to `fetch(request)`
c. If URL matches static extension regex `/\.(js|css|woff2?|png|svg|ico|webmanifest)$/` — cache-first: try `caches.match(request)`, on miss fetch and clone into `nexus-v1` cache, return response
d. All other requests: pass through (no interception)
- `push` event handler: parse `event.data.json()` for `{ title, body, icon, data }`, call `self.registration.showNotification(title, { body, icon: icon || "/android-chrome-192x192.png", badge: "/favicon-32x32.png", data })`
- `notificationclick` event handler: close notification, `clients.openWindow(event.notification.data?.url || "/")`
Do NOT modify `ui/src/main.tsx` — SW registration is already correct.
Do NOT modify `ui/public/site.webmanifest` — manifest is already complete (PWA-02, PWA-07 already satisfied).
</action>
<verify>
<automated>grep -q "nexus-v1" ui/public/sw.js && grep -q "BeforeInstallPromptEvent" ui/src/types/pwa.d.ts && echo "PASS"</automated>
</verify>
<acceptance_criteria>
- `grep "nexus-v1" ui/public/sw.js` returns the cache name
- `grep "paperclip" ui/public/sw.js` returns nothing (old name fully removed)
- `grep "cache-first" ui/public/sw.js` or the cache-first pattern (caches.match before fetch) is present for static assets
- `grep "push" ui/public/sw.js` shows push event listener
- `grep "notificationclick" ui/public/sw.js` shows notification click handler
- `grep "BeforeInstallPromptEvent" ui/src/types/pwa.d.ts` returns the type declaration
- `pnpm --filter @paperclipai/ui exec -- node -e "require('idb')"` succeeds (idb installed)
- `pnpm --filter @paperclipai/server exec -- node -e "require('web-push')"` succeeds (web-push installed)
</acceptance_criteria>
<done>Service worker rewritten with cache-first strategy and nexus-v1 cache name. idb and web-push installed. PWA types declared.</done>
</task>
<task type="auto">
<name>Task 2: Create Wave 0 test stubs for Phase 26 hooks and components</name>
<files>ui/src/hooks/useOfflineQueue.test.ts, ui/src/hooks/useInstallPrompt.test.ts, ui/src/hooks/usePushNotifications.test.ts, ui/src/components/PullToRefresh.test.tsx, ui/src/components/MobileNavBar.test.tsx</files>
<read_first>
- ui/src/components/SwipeToArchive.test.tsx
- .planning/phases/26-pwa-performance/26-RESEARCH.md
</read_first>
<action>
Create 5 test stub files using `it.todo()` (not `it.skip()`) — consistent with Phase 21-25 convention.
1. `ui/src/hooks/useOfflineQueue.test.ts`:
- `describe("useOfflineQueue")` with:
- `it.todo("enqueues message when offline")`
- `it.todo("flushes queue on online event")`
- `it.todo("stops flushing on first failed POST")`
- `it.todo("returns queued message count")`
2. `ui/src/hooks/useInstallPrompt.test.ts`:
- `describe("useInstallPrompt")` with:
- `it.todo("captures beforeinstallprompt event")`
- `it.todo("returns canInstall=true when event captured and not standalone")`
- `it.todo("returns canInstall=false when already installed (standalone)")`
- `it.todo("calls prompt() on the deferred event when promptInstall is called")`
3. `ui/src/hooks/usePushNotifications.test.ts`:
- `describe("usePushNotifications")` with:
- `it.todo("subscribes when permission is granted")`
- `it.todo("does not subscribe when permission is denied")`
- `it.todo("sends subscription to server via POST /api/push/subscribe")`
4. `ui/src/components/PullToRefresh.test.tsx`:
- Add `// @vitest-environment jsdom` pragma at top (mirrors SwipeToArchive.test.tsx pattern)
- `describe("PullToRefresh")` with:
- `it.todo("calls onRefresh after drag exceeds 64px threshold")`
- `it.todo("does not trigger when scrollTop is not 0")`
- `it.todo("resets pull distance on touch end below threshold")`
5. `ui/src/components/MobileNavBar.test.tsx`:
- Add `// @vitest-environment jsdom` pragma at top
- `describe("MobileNavBar")` with:
- `it.todo("renders Dashboard, Chat, and Inbox tabs")`
- `it.todo("highlights active tab with primary color")`
- `it.todo("has minimum 44px touch target height")`
All test stubs should have minimal imports — no service mocks until implementation plans wire them up.
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui test --run useOfflineQueue useInstallPrompt usePushNotifications PullToRefresh MobileNavBar 2>&1 | grep -E "todo|Tests" | head -10</automated>
</verify>
<acceptance_criteria>
- All 5 test files exist at the specified paths
- Each file uses `it.todo()` not `it.skip()`
- `PullToRefresh.test.tsx` and `MobileNavBar.test.tsx` have `// @vitest-environment jsdom` at top
- `pnpm --filter @paperclipai/ui test --run` passes (todos are not failures)
</acceptance_criteria>
<done>5 Wave 0 test stubs created covering PWA-01, PWA-03, PWA-05, PWA-06, PWA-08. All test files use it.todo() and pass vitest run.</done>
</task>
</tasks>
<verification>
- `grep "nexus-v1" ui/public/sw.js` confirms cache name
- `grep -c "paperclip" ui/public/sw.js` returns 0
- All 5 test stub files exist and use `it.todo()`
- `pnpm --filter @paperclipai/ui test --run` passes
- `idb` appears in `ui/package.json` dependencies
- `web-push` appears in `server/package.json` dependencies
</verification>
<success_criteria>
Service worker upgraded to cache-first with nexus-v1 cache. Dependencies installed. PWA types declared. All Wave 0 test stubs created and passing.
</success_criteria>
<output>
After completion, create `.planning/phases/26-pwa-performance/26-00-SUMMARY.md`
</output>

View file

@ -0,0 +1,206 @@
---
phase: 26-pwa-performance
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- ui/src/App.tsx
- ui/vite.config.ts
autonomous: true
requirements:
- PERF-01
- PERF-05
must_haves:
truths:
- "All page components in App.tsx are loaded via React.lazy"
- "Route navigation renders a skeleton fallback during chunk load"
- "Vite build produces separate vendor chunks for heavy libraries"
- "Main entry chunk is significantly smaller than the pre-split 1.4 MB"
artifacts:
- path: "ui/src/App.tsx"
provides: "Lazy-loaded page routes with Suspense"
contains: "lazy("
- path: "ui/vite.config.ts"
provides: "Manual chunk splitting for vendor libraries"
contains: "manualChunks"
key_links:
- from: "ui/src/App.tsx"
to: "ui/src/pages/*"
via: "React.lazy(() => import('./pages/...'))"
pattern: "lazy\\("
- from: "ui/vite.config.ts"
to: "node_modules"
via: "manualChunks vendor splitting"
pattern: "manualChunks"
---
<objective>
Convert all eager page imports in App.tsx to React.lazy with Suspense, and add manual chunk splitting in vite.config.ts to extract heavy vendor libraries into separate bundles.
Purpose: Reduces the main bundle from ~1.4 MB to ~200-400 KB, achieving PERF-01 (initial load < 2s broadband, < 5s on 3G) and contributing to PERF-05 (cached load < 1s with smaller chunks to cache).
Output: Lazy-loaded App.tsx, vendor-chunked vite.config.ts, verified build output.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/26-pwa-performance/26-RESEARCH.md
@ui/src/App.tsx
@ui/vite.config.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: Convert App.tsx page imports to React.lazy with Suspense</name>
<files>ui/src/App.tsx</files>
<read_first>
- ui/src/App.tsx
- ui/src/components/ui/skeleton.tsx
</read_first>
<action>
1. Add `lazy, Suspense` to the React import at the top of App.tsx (import from "react").
2. Convert ALL page component imports (lines 9-47 in current file) from eager to lazy. Each page import becomes:
```
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Companies = lazy(() => import("./pages/Companies"));
const Agents = lazy(() => import("./pages/Agents"));
const AgentDetail = lazy(() => import("./pages/AgentDetail"));
const Projects = lazy(() => import("./pages/Projects"));
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
const ProjectWorkspaceDetail = lazy(() => import("./pages/ProjectWorkspaceDetail"));
const Issues = lazy(() => import("./pages/Issues"));
const IssueDetail = lazy(() => import("./pages/IssueDetail"));
const Routines = lazy(() => import("./pages/Routines"));
const RoutineDetail = lazy(() => import("./pages/RoutineDetail"));
const ExecutionWorkspaceDetail = lazy(() => import("./pages/ExecutionWorkspaceDetail"));
const Goals = lazy(() => import("./pages/Goals"));
const GoalDetail = lazy(() => import("./pages/GoalDetail"));
const Approvals = lazy(() => import("./pages/Approvals"));
const ApprovalDetail = lazy(() => import("./pages/ApprovalDetail"));
const Costs = lazy(() => import("./pages/Costs"));
const Activity = lazy(() => import("./pages/Activity"));
const Inbox = lazy(() => import("./pages/Inbox"));
const CompanySettings = lazy(() => import("./pages/CompanySettings"));
const SkillBrowser = lazy(() => import("./pages/SkillBrowser"));
const SkillDetail = lazy(() => import("./pages/SkillDetail"));
const CompanyExport = lazy(() => import("./pages/CompanyExport"));
const CompanyImport = lazy(() => import("./pages/CompanyImport"));
const DesignGuide = lazy(() => import("./pages/DesignGuide"));
const InstanceGeneralSettings = lazy(() => import("./pages/InstanceGeneralSettings"));
const InstanceSettings = lazy(() => import("./pages/InstanceSettings"));
const InstanceExperimentalSettings = lazy(() => import("./pages/InstanceExperimentalSettings"));
const PluginManager = lazy(() => import("./pages/PluginManager"));
const PluginSettings = lazy(() => import("./pages/PluginSettings"));
const PluginPage = lazy(() => import("./pages/PluginPage"));
const RunTranscriptUxLab = lazy(() => import("./pages/RunTranscriptUxLab"));
const OrgChart = lazy(() => import("./pages/OrgChart"));
const NewAgent = lazy(() => import("./pages/NewAgent"));
const AuthPage = lazy(() => import("./pages/Auth"));
const BoardClaimPage = lazy(() => import("./pages/BoardClaim"));
const CliAuthPage = lazy(() => import("./pages/CliAuth"));
const InviteLandingPage = lazy(() => import("./pages/InviteLanding"));
const NotFoundPage = lazy(() => import("./pages/NotFound"));
```
3. Keep ALL non-page imports as eager (Layout, OnboardingWizard, authApi, healthApi, queryKeys, context hooks, lib utils). These are needed for the app shell and should not be lazy.
4. Ensure each page module uses `export default` — check if pages use named exports. If they use `export function PageName`, the lazy import syntax needs: `lazy(() => import("./pages/PageName").then(m => ({ default: m.PageName })))`. Check the actual export style and adjust accordingly.
5. Wrap the `<Routes>` block inside the `App()` function with a `<Suspense>` boundary:
```tsx
<Suspense fallback={<div className="flex items-center justify-center h-full"><Skeleton className="h-8 w-48" /></div>}>
<Routes>
{/* existing routes unchanged */}
</Routes>
</Suspense>
```
Import `Skeleton` from `@/components/ui/skeleton`.
6. Do NOT lazy-load `OnboardingWizard` — it renders outside Routes and is always needed.
</action>
<verify>
<automated>grep -c "lazy(" ui/src/App.tsx && grep -q "Suspense" ui/src/App.tsx && echo "PASS"</automated>
</verify>
<acceptance_criteria>
- `grep -c "lazy(" ui/src/App.tsx` returns 39 or more (one per page component)
- `grep "Suspense" ui/src/App.tsx` shows Suspense wrapper
- `grep "Skeleton" ui/src/App.tsx` shows skeleton import and usage in fallback
- No eager `import { Dashboard }` style page imports remain
- Non-page imports (Layout, OnboardingWizard, authApi, etc.) remain eager
- `pnpm --filter @paperclipai/ui build` succeeds without errors
</acceptance_criteria>
<done>All page imports converted to React.lazy. Suspense boundary wraps Routes with Skeleton fallback. Build succeeds.</done>
</task>
<task type="auto">
<name>Task 2: Add manual vendor chunk splitting to Vite config</name>
<files>ui/vite.config.ts</files>
<read_first>
- ui/vite.config.ts
- .planning/phases/26-pwa-performance/26-RESEARCH.md
</read_first>
<action>
1. Add `build.rollupOptions.output.manualChunks` to `vite.config.ts`:
```typescript
build: {
rollupOptions: {
output: {
manualChunks: {
"vendor-react": ["react", "react-dom"],
"vendor-router": ["react-router-dom"],
"vendor-query": ["@tanstack/react-query"],
"vendor-markdown": ["react-markdown", "remark-gfm"],
},
},
},
},
```
2. Add chunks one at a time conceptually — but write them all at once. The key concern per RESEARCH Pitfall 5 is circular dependency errors. Start with the safest boundaries (react, react-dom are always clean).
3. Do NOT include `@mdxeditor/editor` in manualChunks — it has complex internal imports that may cause circular dependency errors. Let Vite's default splitting handle it.
4. Do NOT include `rehype-highlight` — it was replaced by manual highlight.js usage in Phase 25.
5. Run `pnpm --filter @paperclipai/ui build` after writing the config to verify no circular dependency errors.
6. If the build fails with circular dependency errors on any specific chunk, remove that chunk from `manualChunks` and retry. Document which chunks were removed and why in the summary.
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui build 2>&1 | tail -5 && grep -q "manualChunks" ui/vite.config.ts && echo "PASS"</automated>
</verify>
<acceptance_criteria>
- `grep "manualChunks" ui/vite.config.ts` shows the configuration
- `grep "vendor-react" ui/vite.config.ts` shows react chunk
- `pnpm --filter @paperclipai/ui build` succeeds without errors
- Build output shows multiple vendor chunk files in dist/assets/
</acceptance_criteria>
<done>Vite config has manualChunks for vendor splitting. Build succeeds. Multiple vendor chunks produced in dist/assets/.</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/ui build` succeeds
- `grep -c "lazy(" ui/src/App.tsx` returns 39+
- `grep "manualChunks" ui/vite.config.ts` shows configuration
- Build output in `ui/dist/assets/` shows separate vendor-*.js chunks
</verification>
<success_criteria>
All page imports lazy-loaded. Vendor chunks extracted. Build succeeds. Main entry bundle significantly reduced from ~1.4 MB baseline.
</success_criteria>
<output>
After completion, create `.planning/phases/26-pwa-performance/26-01-SUMMARY.md`
</output>

View file

@ -0,0 +1,243 @@
---
phase: 26-pwa-performance
plan: 02
type: execute
wave: 2
depends_on:
- 26-00
files_modified:
- ui/src/components/MobileChatView.tsx
- ui/src/components/MobileNavBar.tsx
- ui/src/components/PullToRefresh.tsx
- ui/src/components/ChatPanel.tsx
- ui/src/components/ChatConversationList.tsx
- ui/src/components/ChatInput.tsx
- ui/src/hooks/usePullToRefresh.ts
- ui/src/hooks/useMediaQuery.ts
autonomous: true
requirements:
- PWA-03
- PWA-04
- PWA-05
must_haves:
truths:
- "On screens < 768px, chat renders as a full-screen view, not a slide-in panel"
- "Mobile chat has a 48px header with back button, a sticky input bar at the bottom, and safe area padding"
- "Bottom navigation bar on mobile shows Dashboard, Chat, and Inbox tabs with 44px min height"
- "Pulling down on the conversation list on mobile triggers a refresh after 64px threshold"
- "Chat input has minimum 44px touch targets on mobile and env(safe-area-inset-bottom) padding"
artifacts:
- path: "ui/src/components/MobileChatView.tsx"
provides: "Full-screen mobile chat layout"
min_lines: 40
- path: "ui/src/components/MobileNavBar.tsx"
provides: "Bottom navigation bar for mobile"
min_lines: 30
- path: "ui/src/components/PullToRefresh.tsx"
provides: "Touch gesture wrapper for conversation list refresh"
min_lines: 40
- path: "ui/src/hooks/usePullToRefresh.ts"
provides: "Touch gesture logic (touchstart/touchmove/touchend)"
min_lines: 30
- path: "ui/src/hooks/useMediaQuery.ts"
provides: "Responsive breakpoint hook"
min_lines: 10
key_links:
- from: "ui/src/components/ChatPanel.tsx"
to: "ui/src/components/MobileChatView.tsx"
via: "Conditional render based on useMediaQuery('(min-width: 768px)')"
pattern: "MobileChatView"
- from: "ui/src/components/ChatConversationList.tsx"
to: "ui/src/components/PullToRefresh.tsx"
via: "PullToRefresh wrapper around conversation list on mobile"
pattern: "PullToRefresh"
---
<objective>
Create the responsive mobile layout: MobileChatView (full-screen chat on phones), MobileNavBar (bottom tabs), PullToRefresh (conversation list gesture), and update ChatPanel/ChatInput for mobile-safe rendering.
Purpose: Makes Nexus usable on phones and tablets with proper touch targets, safe area insets, and keyboard-aware layout (PWA-03, PWA-04, PWA-05).
Output: 3 new components, 2 new hooks, 3 updated components.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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/components/ChatConversationList.tsx
@ui/src/components/ChatInput.tsx
@ui/src/components/SwipeToArchive.tsx
<interfaces>
From ui/src/components/ChatPanel.tsx:
```typescript
export function ChatPanel()
// Uses: useChatPanel() for { activeConversationId, setActiveConversationId, ... }
// Uses: useChatMessages(), useStreamingChat(), useBrainstormerDefault(), useChatBookmarks()
// Uses: useChatFileUpload()
// Renders: ChatConversationList, ChatMessageList, ChatInput, ChatAgentSelector, etc.
// Layout class: "hidden md:flex overflow-hidden transition-[width] duration-100 ease-out flex-shrink-0 border-l border-border flex-col bg-background"
```
From ui/src/components/SwipeToArchive.tsx (touch gesture reference):
```typescript
interface SwipeToArchiveProps {
// Uses useRef for startX, useState for offset, native touch events
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create useMediaQuery hook, MobileNavBar, and PullToRefresh components</name>
<files>ui/src/hooks/useMediaQuery.ts, ui/src/hooks/usePullToRefresh.ts, ui/src/components/MobileNavBar.tsx, ui/src/components/PullToRefresh.tsx</files>
<read_first>
- ui/src/components/SwipeToArchive.tsx
- ui/src/components/SwipeToArchive.test.tsx
- .planning/phases/26-pwa-performance/26-UI-SPEC.md
- .planning/phases/26-pwa-performance/26-RESEARCH.md
</read_first>
<action>
1. Create `ui/src/hooks/useMediaQuery.ts`:
- Export `function useMediaQuery(query: string): boolean`
- Use `window.matchMedia(query)` with `addEventListener("change", ...)` for live updates
- Return `matches` state
- SSR-safe: default to `false` if `window` is undefined
2. Create `ui/src/hooks/usePullToRefresh.ts`:
- Export `function usePullToRefresh({ onRefresh, threshold = 64, maxPull = 96, enabled = true }: { onRefresh: () => void; threshold?: number; maxPull?: number; enabled?: boolean })`
- Use `useRef` for `startYRef` (touch start Y coordinate) and `containerRef` (scroll container)
- Use `useState` for `pullDistance` (current pull offset) and `isRefreshing` (loading state)
- Touch handlers (mirror SwipeToArchive.tsx convention — use native DOM events, not React synthetic):
a. `handleTouchStart`: only capture if `containerRef.current?.scrollTop === 0`; store `e.touches[0].clientY` in `startYRef`
b. `handleTouchMove`: calculate `dy = e.touches[0].clientY - startYRef.current`; if `dy > 0`, set `pullDistance` to `Math.min(dy, maxPull)`
c. `handleTouchEnd`: if `pullDistance >= threshold`, call `navigator.vibrate?.(10)` for haptic feedback, set `isRefreshing = true`, call `onRefresh()`, then reset; else reset `pullDistance` to 0
- Return `{ containerRef, pullDistance, isRefreshing, setIsRefreshing }`
- `useEffect` to attach/detach touch listeners on `containerRef.current`
3. Create `ui/src/components/PullToRefresh.tsx`:
- Props: `{ children: ReactNode; onRefresh: () => Promise<void> | void; enabled?: boolean }`
- Use `usePullToRefresh` hook internally
- Render a wrapper `div` with `ref={containerRef}` that contains:
a. A pull indicator div at the top: spinner (using a `Loader2` icon from lucide-react with `animate-spin`) that appears when `pullDistance > 0`, opacity scales with `pullDistance / threshold`
b. Text: show "Pull to refresh" when pulling, "Release to refresh" when `pullDistance >= 64`, spinner-only when `isRefreshing`
c. Children rendered below the indicator
- Spinner color: `text-primary` (uses `var(--primary)` per UI-SPEC)
- Spinner size: 24px (`w-6 h-6`)
4. Create `ui/src/components/MobileNavBar.tsx`:
- Props: `{ activeTab: "dashboard" | "chat" | "inbox" }`
- Render a `nav` element with classes: `fixed bottom-0 left-0 right-0 z-50 flex items-center justify-around border-t border-border bg-background pb-[env(safe-area-inset-bottom)]`
- Minimum height: `min-h-[44px]` (44px touch target per UI-SPEC)
- Three tab buttons, each with:
a. Icon from lucide-react: `LayoutDashboard` (Dashboard), `MessageSquare` (Chat), `Inbox` (Inbox)
b. Label text below icon: "Dashboard", "Chat", "Inbox" in `text-xs`
c. Active state: `text-primary` for icon and label (uses `var(--primary)`)
d. Inactive state: `text-muted-foreground`
e. Each button: `min-h-[44px] min-w-[44px]` touch target, `flex flex-col items-center justify-center gap-0.5`
- Use `@/lib/router` Link component for navigation (Dashboard -> `/{prefix}/dashboard`, Inbox -> `/{prefix}/inbox/mine`)
- Chat tab calls a callback prop `onChatTap` instead of navigating (opens chat view in-place)
</action>
<verify>
<automated>test -f ui/src/hooks/useMediaQuery.ts && test -f ui/src/hooks/usePullToRefresh.ts && test -f ui/src/components/PullToRefresh.tsx && test -f ui/src/components/MobileNavBar.tsx && echo "PASS"</automated>
</verify>
<acceptance_criteria>
- All 4 files exist
- `grep "useMediaQuery" ui/src/hooks/useMediaQuery.ts` shows the hook export
- `grep "threshold" ui/src/hooks/usePullToRefresh.ts` shows 64px default
- `grep "min-h-\[44px\]" ui/src/components/MobileNavBar.tsx` shows touch target
- `grep "text-primary" ui/src/components/MobileNavBar.tsx` shows active tab color
- `grep "safe-area-inset-bottom" ui/src/components/MobileNavBar.tsx` shows safe area padding
- `grep "navigator.vibrate" ui/src/hooks/usePullToRefresh.ts` shows haptic feedback
- `pnpm --filter @paperclipai/ui build` succeeds
</acceptance_criteria>
<done>useMediaQuery hook, usePullToRefresh hook, PullToRefresh component, and MobileNavBar component created with proper touch targets, safe area insets, and haptic feedback.</done>
</task>
<task type="auto">
<name>Task 2: Create MobileChatView and wire ChatPanel for responsive layout</name>
<files>ui/src/components/MobileChatView.tsx, ui/src/components/ChatPanel.tsx, ui/src/components/ChatConversationList.tsx, ui/src/components/ChatInput.tsx</files>
<read_first>
- ui/src/components/ChatPanel.tsx
- ui/src/components/ChatConversationList.tsx
- ui/src/components/ChatInput.tsx
- ui/src/components/MobileNavBar.tsx
- ui/src/hooks/useMediaQuery.ts
- .planning/phases/26-pwa-performance/26-UI-SPEC.md
</read_first>
<action>
1. Create `ui/src/components/MobileChatView.tsx`:
- Full-screen mobile chat layout component
- Props: same data props that ChatPanel passes to its children (activeConversationId, messages, streaming state, etc.) — extract what's needed by reading ChatPanel.tsx
- Layout structure:
a. Outer container: `fixed inset-0 z-40 flex flex-col bg-background`
b. Header: `h-12 flex items-center px-3 border-b border-border gap-2` containing:
- Back button: `<Button variant="ghost" size="icon">` with `ChevronLeft` icon and `aria-label="Back to conversations"`, calls `setActiveConversationId(null)` to return to conversation list
- Conversation title: `<span className="flex-1 truncate text-sm font-medium">` showing conversation title
- Agent selector icon button
c. Message list: `<div className="flex-1 overflow-y-auto">` — renders `ChatMessageList` (reuse existing)
d. Input bar: `<div className="sticky bottom-0 border-t border-border bg-background pb-[env(safe-area-inset-bottom)]">` — 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 `<MobileChatView />` 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 `<PullToRefresh onRefresh={refetch} enabled={isMobile}>` 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
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<verification>
- `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
</verification>
<success_criteria>
Mobile responsive layout complete. Phone users see full-screen chat, bottom nav, pull-to-refresh, and properly sized touch targets. Desktop layout unchanged.
</success_criteria>
<output>
After completion, create `.planning/phases/26-pwa-performance/26-02-SUMMARY.md`
</output>

View file

@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
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<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create useInstallPrompt, useOnlineStatus, useOfflineQueue hooks</name>
<files>ui/src/hooks/useInstallPrompt.ts, ui/src/hooks/useOnlineStatus.ts, ui/src/hooks/useOfflineQueue.ts</files>
<read_first>
- ui/src/types/pwa.d.ts
- ui/src/api/chat.ts
- .planning/phases/26-pwa-performance/26-RESEARCH.md
</read_first>
<action>
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<void>; isIOS: boolean }`
- Use `useState<BeforeInstallPromptEvent | null>(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<void>; flush: () => Promise<void>; 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 }`
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Three hooks created: useOnlineStatus (reactive online/offline state), useInstallPrompt (beforeinstallprompt capture with iOS detection), useOfflineQueue (IndexedDB queue with auto-flush).</done>
</task>
<task type="auto">
<name>Task 2: Create InstallPromptBanner, OfflineBanner, and wire into ChatPanel</name>
<files>ui/src/components/InstallPromptBanner.tsx, ui/src/components/OfflineBanner.tsx, ui/src/components/ChatPanel.tsx</files>
<read_first>
- 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
</read_first>
<action>
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: `<Button size="sm">Add to Home Screen</Button>` — calls `promptInstall()` (or no-op on iOS)
g. Dismiss button: "Not now" as `<Button variant="ghost" size="sm">` — stores `Date.now()` in `localStorage.setItem("nexus.installPromptDismissed", ...)`
- Use `z-50` to layer above other content
2. Create `ui/src/components/OfflineBanner.tsx`:
- Import `useOnlineStatus` hook
- Props: `{ queuedCount?: number }`
- Show when `!isOnline`
- Auto-dismiss: when `isOnline` transitions to true, wait 3 seconds then hide (only if `queuedCount === 0`)
- Layout:
a. Position: `fixed top-0 left-0 right-0 z-50`
b. Dark themes: `bg-amber-900/40 text-amber-200 border-b border-amber-800`
c. Light theme: detect via `prefers-color-scheme` or use Tailwind `dark:` prefix — `bg-amber-50 text-amber-800 border-b border-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:border-amber-800`
d. Content: `WifiOff` icon from lucide-react + text
e. Text (per UI-SPEC):
- No queue: "You're offline — messages will send when you reconnect"
- With queue: "You're offline — {n} message{n === 1 ? '' : 's'} queued"
f. Padding: `px-4 py-2 text-sm flex items-center gap-2`
3. Update `ui/src/components/ChatPanel.tsx`:
- Import `InstallPromptBanner`, `OfflineBanner`, `useOfflineQueue`, `useOnlineStatus`
- Add `useOfflineQueue()` hook call — destructure `{ enqueue, queuedCount }`
- Add `useOnlineStatus()` hook call — destructure `isOnline`
- In `handleSend`: if `!isOnline`, call `enqueue(activeConversationId, content)` instead of the normal chatApi send flow. Show a toast: "Message queued — will send when you're back online" using `pushToast` from `useToast()`
- Render `<OfflineBanner queuedCount={queuedCount} />` at the top of the ChatPanel return JSX
- Render `<InstallPromptBanner />` — it handles its own show/hide logic internally
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<verification>
- `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
</verification>
<success_criteria>
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.
</success_criteria>
<output>
After completion, create `.planning/phases/26-pwa-performance/26-03-SUMMARY.md`
</output>

View file

@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
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
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create push_subscriptions DB schema, pushService, and push routes</name>
<files>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</files>
<read_first>
- 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
</read_first>
<action>
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<string, string> })`:
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
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Push notification backend complete: DB table, VAPID service, API routes, app mounting. Stale subscriptions auto-cleaned.</done>
</task>
<task type="auto">
<name>Task 2: Create push API client, usePushNotifications hook, and NotificationPermissionPrompt</name>
<files>ui/src/api/push.ts, ui/src/hooks/usePushNotifications.ts, ui/src/components/NotificationPermissionPrompt.tsx, ui/src/components/ChatPanel.tsx</files>
<read_first>
- 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
</read_first>
<action>
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<void>` — POST `/api/push/subscribe` with `{ ...subscription.toJSON(), ...meta }`
c. `unsubscribe(endpoint: string): Promise<void>` — 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<void>; unsubscribe: () => Promise<void> }`
- `isSupported`: `"serviceWorker" in navigator && "PushManager" in window && "Notification" in window`
- `permission`: `useState<NotificationPermission>(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" — `<Button size="sm" onClick={subscribe}>` (accent/primary)
- "Not now" — `<Button variant="ghost" size="sm">` — stores `"true"` in `localStorage.setItem("nexus.notifPromptDismissed", "true")`
4. Update `ui/src/components/ChatPanel.tsx`:
- Import `NotificationPermissionPrompt`
- Track `agentResponseCount` — count assistant messages loaded in the current session. A simple `useRef` counter that increments whenever a new assistant message arrives (from streaming or history load) works. Alternatively, derive from the messages array length where `role === "assistant"`.
- Render `<NotificationPermissionPrompt agentResponseCount={agentResponseCount} />` alongside the other banners
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Push notification system complete end-to-end. Client subscribes via SW pushManager, server stores subscriptions, NotificationPermissionPrompt appears after engagement gate.</done>
</task>
</tasks>
<verification>
- `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
</verification>
<success_criteria>
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.
</success_criteria>
<output>
After completion, create `.planning/phases/26-pwa-performance/26-04-SUMMARY.md`
</output>