242 lines
13 KiB
Markdown
242 lines
13 KiB
Markdown
---
|
|
phase: 26-pwa-performance
|
|
plan: 02
|
|
type: execute
|
|
wave: 2
|
|
depends_on:
|
|
- 26-00
|
|
files_modified:
|
|
- 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
|
|
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"
|
|
- "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/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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
|
|
<interfaces>
|
|
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):
|
|
```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 {
|
|
// Uses useRef for startX, useState for offset, native touch events
|
|
}
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<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>
|
|
<action>
|
|
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> | 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.
|
|
</action>
|
|
<verify>
|
|
<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 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>
|
|
<done>useMediaQuery hook, usePullToRefresh hook, and PullToRefresh component created with proper touch handling and haptic feedback.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create MobileChatView and wire ChatPanel for responsive layout</name>
|
|
<files>ui/src/components/MobileChatView.tsx, ui/src/components/ChatPanel.tsx, ui/src/components/ChatConversationList.tsx, ui/src/components/ChatInput.tsx</files>
|
|
<read_first>
|
|
- 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
|
|
</read_first>
|
|
<action>
|
|
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: `<Button variant="ghost" size="icon">` with `ChevronLeft` icon and `aria-label="Back to conversations"`, calls `setActiveConversationId(null)` to return to conversation list
|
|
- Conversation title: `<span className="flex-1 truncate text-sm font-medium">` showing conversation title
|
|
- Agent selector icon button
|
|
c. Message list: `<div className="flex-1 overflow-y-auto">` — renders `ChatMessageList` (reuse existing)
|
|
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`. 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`
|
|
- Import `MobileChatView` from `./MobileChatView`
|
|
- 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`
|
|
- Import `useMediaQuery` from `../hooks/useMediaQuery`
|
|
- Wrap the ScrollArea content in `<PullToRefresh onRefresh={refetch} enabled={isMobile}>` where `refetch` is from `useChatConversations` and `isMobile = !useMediaQuery("(min-width: 768px)")`
|
|
- Each conversation list item already has adequate height but verify `min-h-[48px]` — add it if missing per UI-SPEC touch target rule
|
|
|
|
4. Update `ui/src/components/ChatInput.tsx`:
|
|
- Add `pb-[env(safe-area-inset-bottom)]` class to the outermost input container (only on mobile — use a conditional class or always apply since it's a no-op on desktop)
|
|
- Ensure the Send button has `min-h-[44px] min-w-[44px]` for touch target compliance
|
|
</action>
|
|
<verify>
|
|
<automated>grep -q "MobileChatView" ui/src/components/ChatPanel.tsx && grep -q "100dvh" ui/src/components/MobileChatView.tsx && grep -q "PullToRefresh" ui/src/components/ChatConversationList.tsx && grep -q "safe-area-inset-bottom" ui/src/components/ChatInput.tsx && echo "PASS"</automated>
|
|
</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, 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>
|
|
|
|
<verification>
|
|
- `pnpm --filter @paperclipai/ui build` succeeds
|
|
- MobileChatView uses `100dvh` not `100vh`
|
|
- 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 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>
|
|
After completion, create `.planning/phases/26-pwa-performance/26-02-SUMMARY.md`
|
|
</output>
|