fix(26): revise plans based on checker feedback
This commit is contained in:
parent
5f33a414e9
commit
45016856e5
4 changed files with 103 additions and 76 deletions
|
|
@ -11,7 +11,6 @@ files_modified:
|
|||
- 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
|
||||
|
|
@ -48,7 +47,7 @@ must_haves:
|
|||
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.
|
||||
Output: Upgraded sw.js, pwa.d.ts type declarations, 4 test stub files, idb + web-push installed.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
|
|
@ -120,13 +119,13 @@ Do NOT modify `ui/public/site.webmanifest` — manifest is already complete (PWA
|
|||
|
||||
<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>
|
||||
<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</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.
|
||||
Create 4 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:
|
||||
|
|
@ -155,25 +154,20 @@ Create 5 test stub files using `it.todo()` (not `it.skip()`) — consistent with
|
|||
- `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.
|
||||
|
||||
NOTE: No MobileNavBar.test.tsx is created — the existing MobileBottomNav in Layout.tsx already handles global mobile navigation. If tests are needed for MobileBottomNav, they would belong to a separate plan.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @paperclipai/ui test --run useOfflineQueue useInstallPrompt usePushNotifications PullToRefresh MobileNavBar 2>&1 | grep -E "todo|Tests" | head -10</automated>
|
||||
<automated>pnpm --filter @paperclipai/ui test --run useOfflineQueue useInstallPrompt usePushNotifications PullToRefresh 2>&1 | grep -E "todo|Tests" | head -10</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- All 5 test files exist at the specified paths
|
||||
- All 4 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
|
||||
- `PullToRefresh.test.tsx` has `// @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>
|
||||
<done>4 Wave 0 test stubs created covering PWA-01, PWA-03, PWA-06, PWA-08. All test files use it.todo() and pass vitest run.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
|
@ -181,7 +175,7 @@ All test stubs should have minimal imports — no service mocks until implementa
|
|||
<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()`
|
||||
- All 4 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
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ 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
|
||||
|
|
@ -23,16 +22,13 @@ 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"
|
||||
- "Existing MobileBottomNav in Layout.tsx already provides global bottom navigation on mobile — no new nav bar component needed"
|
||||
- "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
|
||||
|
|
@ -54,10 +50,12 @@ must_haves:
|
|||
---
|
||||
|
||||
<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.
|
||||
Create the responsive mobile layout: MobileChatView (full-screen chat on phones), PullToRefresh (conversation list gesture), and update ChatPanel/ChatInput for mobile-safe rendering.
|
||||
|
||||
NOTE: The global MobileBottomNav component already exists in Layout.tsx and provides bottom tab navigation (Dashboard, Issues, Create, Agents, Inbox) on all mobile pages. This plan does NOT create a new MobileNavBar — it leverages the existing global nav.
|
||||
|
||||
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.
|
||||
Output: 2 new components, 2 new hooks, 3 updated components.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
|
|
@ -75,6 +73,8 @@ Output: 3 new components, 2 new hooks, 3 updated components.
|
|||
@ui/src/components/ChatConversationList.tsx
|
||||
@ui/src/components/ChatInput.tsx
|
||||
@ui/src/components/SwipeToArchive.tsx
|
||||
@ui/src/components/Layout.tsx
|
||||
@ui/src/components/MobileBottomNav.tsx
|
||||
|
||||
<interfaces>
|
||||
From ui/src/components/ChatPanel.tsx:
|
||||
|
|
@ -87,6 +87,14 @@ export function ChatPanel()
|
|||
// 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/Layout.tsx (existing mobile nav):
|
||||
```typescript
|
||||
// Line 17: import { MobileBottomNav } from "./MobileBottomNav";
|
||||
// Line 468: {isMobile && <MobileBottomNav visible={mobileNavVisible} />}
|
||||
// MobileBottomNav is ALREADY wired into Layout globally — renders on all mobile pages
|
||||
// Provides: Dashboard, Issues, Create, Agents, Inbox tabs
|
||||
```
|
||||
|
||||
From ui/src/components/SwipeToArchive.tsx (touch gesture reference):
|
||||
```typescript
|
||||
interface SwipeToArchiveProps {
|
||||
|
|
@ -99,11 +107,12 @@ interface SwipeToArchiveProps {
|
|||
<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>
|
||||
<name>Task 1: Create useMediaQuery hook and PullToRefresh component</name>
|
||||
<files>ui/src/hooks/useMediaQuery.ts, ui/src/hooks/usePullToRefresh.ts, ui/src/components/PullToRefresh.tsx</files>
|
||||
<read_first>
|
||||
- ui/src/components/SwipeToArchive.tsx
|
||||
- ui/src/components/SwipeToArchive.test.tsx
|
||||
- ui/src/components/MobileBottomNav.tsx
|
||||
- .planning/phases/26-pwa-performance/26-UI-SPEC.md
|
||||
- .planning/phases/26-pwa-performance/26-RESEARCH.md
|
||||
</read_first>
|
||||
|
|
@ -135,33 +144,19 @@ interface SwipeToArchiveProps {
|
|||
- 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)
|
||||
NOTE: No MobileNavBar component is created. The existing MobileBottomNav in Layout.tsx already handles global mobile navigation.
|
||||
</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>
|
||||
<automated>test -f ui/src/hooks/useMediaQuery.ts && test -f ui/src/hooks/usePullToRefresh.ts && test -f ui/src/components/PullToRefresh.tsx && echo "PASS"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- All 4 files exist
|
||||
- All 3 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>
|
||||
<done>useMediaQuery hook, usePullToRefresh hook, and PullToRefresh component created with proper touch handling and haptic feedback.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
|
|
@ -171,7 +166,8 @@ interface SwipeToArchiveProps {
|
|||
- ui/src/components/ChatPanel.tsx
|
||||
- ui/src/components/ChatConversationList.tsx
|
||||
- ui/src/components/ChatInput.tsx
|
||||
- ui/src/components/MobileNavBar.tsx
|
||||
- ui/src/components/Layout.tsx
|
||||
- ui/src/components/MobileBottomNav.tsx
|
||||
- ui/src/hooks/useMediaQuery.ts
|
||||
- .planning/phases/26-pwa-performance/26-UI-SPEC.md
|
||||
</read_first>
|
||||
|
|
@ -189,8 +185,8 @@ interface SwipeToArchiveProps {
|
|||
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
|
||||
a. When `activeConversationId` is null: show conversation list (full screen) wrapped in `PullToRefresh`. Add bottom padding `pb-16` to account for the existing MobileBottomNav at the bottom.
|
||||
b. When `activeConversationId` is set: show header + message list + input (full screen, MobileBottomNav is hidden by Layout when chat is active via `mobileNavVisible` logic)
|
||||
|
||||
2. Update `ui/src/components/ChatPanel.tsx`:
|
||||
- Import `useMediaQuery` from `../hooks/useMediaQuery`
|
||||
|
|
@ -198,6 +194,7 @@ interface SwipeToArchiveProps {
|
|||
- 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.
|
||||
- Do NOT render any MobileNavBar — the global MobileBottomNav in Layout.tsx handles navigation.
|
||||
|
||||
3. Update `ui/src/components/ChatConversationList.tsx`:
|
||||
- Import `PullToRefresh` from `./PullToRefresh`
|
||||
|
|
@ -214,13 +211,15 @@ interface SwipeToArchiveProps {
|
|||
</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`
|
||||
- `MobileChatView.tsx` does NOT render any MobileNavBar — relies on Layout's MobileBottomNav
|
||||
- `MobileChatView.tsx` conversation list view has `pb-16` to account for MobileBottomNav
|
||||
- `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>
|
||||
<done>MobileChatView renders full-screen chat on mobile, leveraging existing MobileBottomNav from Layout for navigation. ChatPanel conditionally renders mobile vs desktop. ChatConversationList has pull-to-refresh. ChatInput has safe area padding and proper touch targets.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
|
@ -228,14 +227,14 @@ interface SwipeToArchiveProps {
|
|||
<verification>
|
||||
- `pnpm --filter @paperclipai/ui build` succeeds
|
||||
- MobileChatView uses `100dvh` not `100vh`
|
||||
- MobileNavBar has 44px minimum touch targets
|
||||
- No MobileNavBar component created — existing MobileBottomNav in Layout handles global mobile nav
|
||||
- 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.
|
||||
Mobile responsive layout complete. Phone users see full-screen chat with pull-to-refresh and properly sized touch targets. Global MobileBottomNav (already in Layout) provides navigation across all pages. Desktop layout unchanged.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
phase: 26-pwa-performance
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
wave: 3
|
||||
depends_on:
|
||||
- 26-00
|
||||
- 26-02
|
||||
files_modified:
|
||||
- ui/src/components/InstallPromptBanner.tsx
|
||||
- ui/src/components/OfflineBanner.tsx
|
||||
|
|
@ -74,6 +75,7 @@ Output: 2 new components, 3 new hooks, ChatPanel integration for offline queue.
|
|||
@.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
|
||||
|
||||
|
|
@ -170,7 +172,7 @@ interface BeforeInstallPromptEvent extends Event {
|
|||
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)
|
||||
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`
|
||||
|
|
@ -201,6 +203,8 @@ interface BeforeInstallPromptEvent extends Event {
|
|||
- 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
|
||||
|
||||
NOTE: ChatPanel.tsx was also modified by plan 26-02 (MobileChatView wiring). This plan depends on 26-02 completing first. Read the current state of ChatPanel.tsx (post-26-02) before making changes.
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
phase: 26-pwa-performance
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 3
|
||||
wave: 4
|
||||
depends_on:
|
||||
- 26-00
|
||||
- 26-02
|
||||
|
|
@ -10,6 +10,7 @@ depends_on:
|
|||
files_modified:
|
||||
- packages/db/src/schema/push_subscriptions.ts
|
||||
- packages/db/src/schema/index.ts
|
||||
- packages/db/src/migrations/0055_create_push_subscriptions.sql
|
||||
- server/src/services/pushService.ts
|
||||
- server/src/routes/push.ts
|
||||
- server/src/app.ts
|
||||
|
|
@ -27,12 +28,15 @@ must_haves:
|
|||
- "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"
|
||||
- "Push subscriptions are stored in PostgreSQL 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: "packages/db/src/migrations/0055_create_push_subscriptions.sql"
|
||||
provides: "SQL migration for push_subscriptions table"
|
||||
contains: "CREATE TABLE"
|
||||
- path: "server/src/services/pushService.ts"
|
||||
provides: "VAPID config + sendPush helper"
|
||||
contains: "sendNotification"
|
||||
|
|
@ -64,7 +68,7 @@ must_haves:
|
|||
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.
|
||||
Output: 1 new DB table + migration, 1 server service, 1 server route file, 1 client API module, 1 hook, 1 component.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
|
|
@ -78,16 +82,17 @@ Output: 1 new DB table, 1 server service, 1 server route file, 1 client API modu
|
|||
@.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-03-SUMMARY.md
|
||||
@packages/db/src/schema/index.ts
|
||||
@server/src/app.ts
|
||||
@server/src/routes/chat.ts
|
||||
|
||||
<interfaces>
|
||||
From packages/db/src/schema (Drizzle pattern):
|
||||
From packages/db/src/schema (Drizzle pg-core pattern — matches all existing tables):
|
||||
```typescript
|
||||
// All tables use: sqliteTable(), text(), integer(), index()
|
||||
// UUID ids via text("id").primaryKey()
|
||||
// Timestamps via integer("created_at", { mode: "timestamp" })
|
||||
// All tables use: pgTable(), uuid(), text(), timestamp(), index() from "drizzle-orm/pg-core"
|
||||
// UUID ids via uuid("id").primaryKey().defaultRandom()
|
||||
// Timestamps via timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
||||
// Index callbacks use object-syntax: (table) => ({})
|
||||
```
|
||||
|
||||
|
|
@ -108,41 +113,60 @@ From ui/public/sw.js (created in Plan 00):
|
|||
<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>
|
||||
<name>Task 1: Create push_subscriptions DB schema, migration, pushService, and push routes</name>
|
||||
<files>packages/db/src/schema/push_subscriptions.ts, packages/db/src/schema/index.ts, packages/db/src/migrations/0055_create_push_subscriptions.sql, 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/chat_files.ts
|
||||
- packages/db/src/schema/index.ts
|
||||
- packages/db/src/migrations/0053_create_chat_files.sql
|
||||
- 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`
|
||||
- Import from `drizzle-orm/pg-core`: `pgTable`, `uuid`, `text`, `timestamp`, `index`
|
||||
- Define `pushSubscriptions` table with columns:
|
||||
a. `id` — `text("id").primaryKey()` (UUID, generated at insert time)
|
||||
a. `id` — `uuid("id").primaryKey().defaultRandom()`
|
||||
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)
|
||||
e. `userId` — `uuid("user_id")` (nullable — local mode may not have auth)
|
||||
f. `companyId` — `uuid("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())`
|
||||
h. `createdAt` — `timestamp("created_at", { withTimezone: true }).notNull().defaultNow()`
|
||||
- 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`:
|
||||
2. Create `packages/db/src/migrations/0055_create_push_subscriptions.sql`:
|
||||
- Follow the same pattern as `0053_create_chat_files.sql`
|
||||
- SQL:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS "push_subscriptions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"endpoint" text NOT NULL,
|
||||
"p256dh" text NOT NULL,
|
||||
"auth" text NOT NULL,
|
||||
"user_id" uuid,
|
||||
"company_id" uuid,
|
||||
"device_label" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "push_sub_endpoint_idx" ON "push_subscriptions" ("endpoint");
|
||||
```
|
||||
|
||||
3. Update `packages/db/src/schema/index.ts`:
|
||||
- Add `export * from "./push_subscriptions";`
|
||||
|
||||
3. Create `server/src/services/pushService.ts`:
|
||||
4. 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
|
||||
- `saveSubscription(db, { endpoint, p256dh, auth, userId?, companyId?, deviceLabel? })`: generate UUID via `crypto.randomUUID()`, 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)
|
||||
|
|
@ -151,24 +175,27 @@ From ui/public/sw.js (created in Plan 00):
|
|||
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`:
|
||||
5. 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`:
|
||||
6. 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>
|
||||
<automated>grep -q "pgTable" packages/db/src/schema/push_subscriptions.ts && grep -q "push_subscriptions" packages/db/src/schema/index.ts && grep -q "CREATE TABLE" packages/db/src/migrations/0055_create_push_subscriptions.sql && 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 `pgTable` from `drizzle-orm/pg-core` (NOT sqlite-core)
|
||||
- `push_subscriptions.ts` uses `uuid("id").primaryKey().defaultRandom()` for id column
|
||||
- `push_subscriptions.ts` uses `timestamp("created_at", { withTimezone: true }).notNull().defaultNow()` for timestamp
|
||||
- `push_subscriptions.ts` uses object-syntax `(table) => ({})` for index callback
|
||||
- `0055_create_push_subscriptions.sql` migration exists with CREATE TABLE and CREATE INDEX
|
||||
- `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
|
||||
|
|
@ -176,7 +203,7 @@ From ui/public/sw.js (created in Plan 00):
|
|||
- `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>
|
||||
<done>Push notification backend complete: DB table with pg-core schema, SQL migration, VAPID service, API routes, app mounting. Stale subscriptions auto-cleaned.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
|
|
@ -235,6 +262,8 @@ From ui/public/sw.js (created in Plan 00):
|
|||
- 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
|
||||
|
||||
NOTE: ChatPanel.tsx was also modified by plans 26-02 and 26-03. This plan depends on both completing first. Read the current state of ChatPanel.tsx (post-26-03) before making changes.
|
||||
</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>
|
||||
|
|
@ -256,7 +285,8 @@ From ui/public/sw.js (created in Plan 00):
|
|||
|
||||
<verification>
|
||||
- `pnpm --filter @paperclipai/ui build` succeeds
|
||||
- Push subscription DB table defined with proper columns
|
||||
- Push subscription DB table uses pgTable with uuid/timestamp (pg-core, not sqlite-core)
|
||||
- Migration file 0055 exists with CREATE TABLE SQL
|
||||
- Server VAPID init is graceful (no crash if env vars missing)
|
||||
- Push routes mounted at `/api/push`
|
||||
- Client hook subscribes via SW pushManager
|
||||
|
|
@ -264,7 +294,7 @@ From ui/public/sw.js (created in Plan 00):
|
|||
</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.
|
||||
Push notifications wired end-to-end. Server stores subscriptions in PostgreSQL, 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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue