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), 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.
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)
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"
<acceptance_criteria>
All 4 files exist
grep "useMediaQuery" ui/src/hooks/useMediaQuery.ts shows the hook export