nexus/.planning/phases/26-pwa-performance/26-02-PLAN.md

13 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
26-pwa-performance 02 execute 2
26-00
ui/src/components/MobileChatView.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
true
PWA-03
PWA-04
PWA-05
truths artifacts key_links
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
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
path provides min_lines
ui/src/components/MobileChatView.tsx Full-screen mobile chat layout 40
path provides min_lines
ui/src/components/PullToRefresh.tsx Touch gesture wrapper for conversation list refresh 40
path provides min_lines
ui/src/hooks/usePullToRefresh.ts Touch gesture logic (touchstart/touchmove/touchend) 30
path provides min_lines
ui/src/hooks/useMediaQuery.ts Responsive breakpoint hook 10
from to via pattern
ui/src/components/ChatPanel.tsx ui/src/components/MobileChatView.tsx Conditional render based on useMediaQuery('(min-width: 768px)') MobileChatView
from to via pattern
ui/src/components/ChatConversationList.tsx ui/src/components/PullToRefresh.tsx PullToRefresh wrapper around conversation list on mobile PullToRefresh
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: 2 new components, 2 new hooks, 3 updated components.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 @ui/src/components/Layout.tsx @ui/src/components/MobileBottomNav.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/Layout.tsx (existing mobile nav):

// 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):

interface SwipeToArchiveProps {
  // Uses useRef for startX, useState for offset, native touch events
}
Task 1: Create useMediaQuery hook and PullToRefresh component ui/src/hooks/useMediaQuery.ts, ui/src/hooks/usePullToRefresh.ts, ui/src/components/PullToRefresh.tsx - 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 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
  1. 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
  2. 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)

NOTE: No MobileNavBar component is created. The existing MobileBottomNav in Layout.tsx already handles global mobile navigation. test -f ui/src/hooks/useMediaQuery.ts && test -f ui/src/hooks/usePullToRefresh.ts && test -f ui/src/components/PullToRefresh.tsx && echo "PASS" <acceptance_criteria> - 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 "navigator.vibrate" ui/src/hooks/usePullToRefresh.ts shows haptic feedback - pnpm --filter @paperclipai/ui build succeeds </acceptance_criteria> useMediaQuery hook, usePullToRefresh hook, and PullToRefresh component created with proper touch handling 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/Layout.tsx - ui/src/components/MobileBottomNav.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: `