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.
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):
interfaceSwipeToArchiveProps{// 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
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
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: `