From 5f33a414e90b54f8dd9bb391acaf7800b7fa781b Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Thu, 2 Apr 2026 01:44:49 +0000 Subject: [PATCH] =?UTF-8?q?docs(26):=20create=20phase=20plan=20=E2=80=94?= =?UTF-8?q?=205=20plans=20across=203=20waves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .planning/ROADMAP.md | 12 +- .../phases/26-pwa-performance/26-00-PLAN.md | 196 +++++++++++++ .../phases/26-pwa-performance/26-01-PLAN.md | 206 +++++++++++++ .../phases/26-pwa-performance/26-02-PLAN.md | 243 ++++++++++++++++ .../phases/26-pwa-performance/26-03-PLAN.md | 236 +++++++++++++++ .../phases/26-pwa-performance/26-04-PLAN.md | 272 ++++++++++++++++++ 6 files changed, 1163 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/26-pwa-performance/26-00-PLAN.md create mode 100644 .planning/phases/26-pwa-performance/26-01-PLAN.md create mode 100644 .planning/phases/26-pwa-performance/26-02-PLAN.md create mode 100644 .planning/phases/26-pwa-performance/26-03-PLAN.md create mode 100644 .planning/phases/26-pwa-performance/26-04-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index b837f69f..811fa6ca 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 | - | diff --git a/.planning/phases/26-pwa-performance/26-00-PLAN.md b/.planning/phases/26-pwa-performance/26-00-PLAN.md new file mode 100644 index 00000000..1849f759 --- /dev/null +++ b/.planning/phases/26-pwa-performance/26-00-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/26-pwa-performance/26-RESEARCH.md +@.planning/phases/26-pwa-performance/26-UI-SPEC.md +@ui/public/sw.js +@ui/src/main.tsx + + + + + + Task 1: Install dependencies, create PWA types, and rewrite service worker + ui/public/sw.js, ui/src/types/pwa.d.ts + + - ui/public/sw.js + - ui/src/main.tsx + - ui/public/site.webmanifest + - .planning/phases/26-pwa-performance/26-RESEARCH.md + + +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` 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). + + + grep -q "nexus-v1" ui/public/sw.js && grep -q "BeforeInstallPromptEvent" ui/src/types/pwa.d.ts && echo "PASS" + + + - `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) + + Service worker rewritten with cache-first strategy and nexus-v1 cache name. idb and web-push installed. PWA types declared. + + + + Task 2: Create Wave 0 test stubs for Phase 26 hooks and components + 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 + + - ui/src/components/SwipeToArchive.test.tsx + - .planning/phases/26-pwa-performance/26-RESEARCH.md + + +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. + + + pnpm --filter @paperclipai/ui test --run useOfflineQueue useInstallPrompt usePushNotifications PullToRefresh MobileNavBar 2>&1 | grep -E "todo|Tests" | head -10 + + + - 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) + + 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. + + + + + +- `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 + + + +Service worker upgraded to cache-first with nexus-v1 cache. Dependencies installed. PWA types declared. All Wave 0 test stubs created and passing. + + + +After completion, create `.planning/phases/26-pwa-performance/26-00-SUMMARY.md` + diff --git a/.planning/phases/26-pwa-performance/26-01-PLAN.md b/.planning/phases/26-pwa-performance/26-01-PLAN.md new file mode 100644 index 00000000..44bcde48 --- /dev/null +++ b/.planning/phases/26-pwa-performance/26-01-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/26-pwa-performance/26-RESEARCH.md +@ui/src/App.tsx +@ui/vite.config.ts + + + + + + Task 1: Convert App.tsx page imports to React.lazy with Suspense + ui/src/App.tsx + + - ui/src/App.tsx + - ui/src/components/ui/skeleton.tsx + + +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 `` block inside the `App()` function with a `` boundary: + ```tsx + }> + + {/* existing routes unchanged */} + + + ``` + Import `Skeleton` from `@/components/ui/skeleton`. + +6. Do NOT lazy-load `OnboardingWizard` — it renders outside Routes and is always needed. + + + grep -c "lazy(" ui/src/App.tsx && grep -q "Suspense" ui/src/App.tsx && echo "PASS" + + + - `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 + + All page imports converted to React.lazy. Suspense boundary wraps Routes with Skeleton fallback. Build succeeds. + + + + Task 2: Add manual vendor chunk splitting to Vite config + ui/vite.config.ts + + - ui/vite.config.ts + - .planning/phases/26-pwa-performance/26-RESEARCH.md + + +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. + + + pnpm --filter @paperclipai/ui build 2>&1 | tail -5 && grep -q "manualChunks" ui/vite.config.ts && echo "PASS" + + + - `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/ + + Vite config has manualChunks for vendor splitting. Build succeeds. Multiple vendor chunks produced in dist/assets/. + + + + + +- `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 + + + +All page imports lazy-loaded. Vendor chunks extracted. Build succeeds. Main entry bundle significantly reduced from ~1.4 MB baseline. + + + +After completion, create `.planning/phases/26-pwa-performance/26-01-SUMMARY.md` + diff --git a/.planning/phases/26-pwa-performance/26-02-PLAN.md b/.planning/phases/26-pwa-performance/26-02-PLAN.md new file mode 100644 index 00000000..fd55407e --- /dev/null +++ b/.planning/phases/26-pwa-performance/26-02-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/26-pwa-performance/26-RESEARCH.md +@.planning/phases/26-pwa-performance/26-UI-SPEC.md +@ui/src/components/ChatPanel.tsx +@ui/src/components/ChatConversationList.tsx +@ui/src/components/ChatInput.tsx +@ui/src/components/SwipeToArchive.tsx + + +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 +} +``` + + + + + + + Task 1: Create useMediaQuery hook, MobileNavBar, and PullToRefresh components + ui/src/hooks/useMediaQuery.ts, ui/src/hooks/usePullToRefresh.ts, ui/src/components/MobileNavBar.tsx, ui/src/components/PullToRefresh.tsx + + - 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 + + +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; 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) + + + 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" + + + - 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 + + useMediaQuery hook, usePullToRefresh hook, PullToRefresh component, and MobileNavBar component created with proper touch targets, safe area insets, and haptic feedback. + + + + Task 2: Create MobileChatView and wire ChatPanel for responsive layout + ui/src/components/MobileChatView.tsx, ui/src/components/ChatPanel.tsx, ui/src/components/ChatConversationList.tsx, ui/src/components/ChatInput.tsx + + - 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 + + +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: `` — calls `promptInstall()` (or no-op on iOS) + g. Dismiss button: "Not now" as `