docs(08-mobile-pwa): create phase plan
This commit is contained in:
351
.planning/phases/08-mobile-pwa/08-01-PLAN.md
Normal file
351
.planning/phases/08-mobile-pwa/08-01-PLAN.md
Normal file
@@ -0,0 +1,351 @@
|
||||
---
|
||||
phase: 08-mobile-pwa
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- packages/portal/package.json
|
||||
- packages/portal/next.config.ts
|
||||
- packages/portal/app/manifest.ts
|
||||
- packages/portal/app/sw.ts
|
||||
- packages/portal/app/layout.tsx
|
||||
- packages/portal/app/(dashboard)/layout.tsx
|
||||
- packages/portal/components/mobile-nav.tsx
|
||||
- packages/portal/components/mobile-more-sheet.tsx
|
||||
- packages/portal/components/sw-register.tsx
|
||||
- packages/portal/components/offline-banner.tsx
|
||||
- packages/portal/lib/use-offline.ts
|
||||
- packages/portal/public/icon-192.png
|
||||
- packages/portal/public/icon-512.png
|
||||
- packages/portal/public/icon-maskable-192.png
|
||||
- packages/portal/public/apple-touch-icon.png
|
||||
- packages/portal/public/badge-72.png
|
||||
- packages/portal/messages/en.json
|
||||
- packages/portal/messages/es.json
|
||||
- packages/portal/messages/pt.json
|
||||
autonomous: true
|
||||
requirements:
|
||||
- MOB-01
|
||||
- MOB-02
|
||||
- MOB-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Desktop sidebar is hidden on screens < 768px; bottom tab bar appears instead"
|
||||
- "Bottom tab bar has 5 items: Dashboard, Employees, Chat, Usage, More"
|
||||
- "More sheet opens with Billing, API Keys, Users, Platform, Settings, Sign Out (RBAC-filtered)"
|
||||
- "Main content has bottom padding on mobile to clear the tab bar"
|
||||
- "Portal is installable as a PWA with manifest, icons, and service worker"
|
||||
- "Offline banner appears when network is lost"
|
||||
- "All existing pages remain functional on desktop (no regression)"
|
||||
artifacts:
|
||||
- path: "packages/portal/components/mobile-nav.tsx"
|
||||
provides: "Bottom tab bar navigation for mobile"
|
||||
exports: ["MobileNav"]
|
||||
- path: "packages/portal/components/mobile-more-sheet.tsx"
|
||||
provides: "Bottom sheet for secondary nav items"
|
||||
exports: ["MobileMoreSheet"]
|
||||
- path: "packages/portal/app/manifest.ts"
|
||||
provides: "PWA manifest with K monogram icons"
|
||||
exports: ["default"]
|
||||
- path: "packages/portal/app/sw.ts"
|
||||
provides: "Service worker with Serwist precaching"
|
||||
- path: "packages/portal/components/sw-register.tsx"
|
||||
provides: "Service worker registration client component"
|
||||
exports: ["ServiceWorkerRegistration"]
|
||||
- path: "packages/portal/components/offline-banner.tsx"
|
||||
provides: "Offline status indicator"
|
||||
exports: ["OfflineBanner"]
|
||||
key_links:
|
||||
- from: "packages/portal/app/(dashboard)/layout.tsx"
|
||||
to: "packages/portal/components/mobile-nav.tsx"
|
||||
via: "conditional render with hidden md:flex / md:hidden"
|
||||
pattern: "MobileNav.*md:hidden"
|
||||
- from: "packages/portal/next.config.ts"
|
||||
to: "packages/portal/app/sw.ts"
|
||||
via: "withSerwist wrapper generates public/sw.js from app/sw.ts"
|
||||
pattern: "withSerwist"
|
||||
- from: "packages/portal/app/layout.tsx"
|
||||
to: "packages/portal/components/sw-register.tsx"
|
||||
via: "mounted in body for service worker registration"
|
||||
pattern: "ServiceWorkerRegistration"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Responsive mobile layout foundation and PWA infrastructure. Desktop sidebar becomes a bottom tab bar on mobile, PWA manifest and service worker enable installability, and offline detection provides status feedback.
|
||||
|
||||
Purpose: This is the structural foundation that all other mobile plans build on. The layout split (sidebar vs tab bar) affects every page, and PWA infrastructure (manifest + service worker) is required before push notifications or offline caching can work.
|
||||
|
||||
Output: Mobile-responsive dashboard layout, bottom tab bar with More sheet, PWA manifest with K monogram icons, Serwist service worker, offline banner component.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/08-mobile-pwa/08-CONTEXT.md
|
||||
@.planning/phases/08-mobile-pwa/08-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
From packages/portal/components/nav.tsx:
|
||||
```typescript
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
allowedRoles?: string[];
|
||||
};
|
||||
|
||||
// Nav items array (lines 43-53):
|
||||
// Dashboard, Tenants (platform_admin), Employees, Chat, Usage,
|
||||
// Billing (admin+), API Keys (admin+), Users (admin+), Platform (platform_admin)
|
||||
// Role filtering: visibleItems = navItems.filter(item => !item.allowedRoles || item.allowedRoles.includes(role))
|
||||
```
|
||||
|
||||
From packages/portal/app/(dashboard)/layout.tsx:
|
||||
```typescript
|
||||
// Current layout: flex min-h-screen, <Nav /> sidebar + <main> content
|
||||
// Wraps with SessionProvider, QueryClientProvider, SessionSync, ImpersonationBanner
|
||||
```
|
||||
|
||||
From packages/portal/app/layout.tsx:
|
||||
```typescript
|
||||
// Root layout: Server Component with next-intl provider
|
||||
// Exports metadata: Metadata
|
||||
// No viewport export yet (needs viewportFit: 'cover' for safe areas)
|
||||
```
|
||||
|
||||
From packages/portal/next.config.ts:
|
||||
```typescript
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
||||
const nextConfig: NextConfig = { output: "standalone" };
|
||||
export default withNextIntl(nextConfig);
|
||||
// Must compose: withNextIntl(withSerwist(nextConfig))
|
||||
```
|
||||
|
||||
From packages/portal/proxy.ts:
|
||||
```typescript
|
||||
const CUSTOMER_OPERATOR_RESTRICTED = ["/billing", "/settings/api-keys", "/users", "/admin", "/agents/new"];
|
||||
const PLATFORM_ADMIN_ONLY = ["/admin"];
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Install PWA dependencies, create icons, manifest, service worker, and offline utilities</name>
|
||||
<files>
|
||||
packages/portal/package.json,
|
||||
packages/portal/next.config.ts,
|
||||
packages/portal/app/manifest.ts,
|
||||
packages/portal/app/sw.ts,
|
||||
packages/portal/app/layout.tsx,
|
||||
packages/portal/components/sw-register.tsx,
|
||||
packages/portal/components/offline-banner.tsx,
|
||||
packages/portal/lib/use-offline.ts,
|
||||
packages/portal/public/icon-192.png,
|
||||
packages/portal/public/icon-512.png,
|
||||
packages/portal/public/icon-maskable-192.png,
|
||||
packages/portal/public/apple-touch-icon.png,
|
||||
packages/portal/public/badge-72.png
|
||||
</files>
|
||||
<action>
|
||||
1. Install PWA dependencies in packages/portal:
|
||||
```bash
|
||||
npm install @serwist/next serwist web-push idb
|
||||
npm install -D @types/web-push
|
||||
```
|
||||
|
||||
2. Generate PWA icon assets. Create a Node.js script using `sharp` (install as devDep if needed) or canvas to generate the K monogram icons. The "K" should be bold white text on a dark gradient background (#0f0f1a to #1a1a2e with a subtle purple/blue accent). Generate:
|
||||
- public/icon-192.png (192x192) — K monogram on gradient
|
||||
- public/icon-512.png (512x512) — K monogram on gradient
|
||||
- public/icon-maskable-192.png (192x192 with 10% safe-zone padding)
|
||||
- public/apple-touch-icon.png (180x180)
|
||||
- public/badge-72.png (72x72, monochrome white K on transparent)
|
||||
If sharp is complex, create simple SVGs and convert, or use canvas. The icons must exist as real PNG files.
|
||||
|
||||
3. Create `app/manifest.ts` using Next.js built-in manifest file convention:
|
||||
```typescript
|
||||
import type { MetadataRoute } from 'next'
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'Konstruct',
|
||||
short_name: 'Konstruct',
|
||||
description: 'AI Workforce Platform',
|
||||
start_url: '/dashboard',
|
||||
display: 'standalone',
|
||||
background_color: '#0f0f1a',
|
||||
theme_color: '#0f0f1a',
|
||||
orientation: 'portrait',
|
||||
icons: [
|
||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
{ src: '/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. Create `app/sw.ts` — Serwist service worker source:
|
||||
```typescript
|
||||
import { defaultCache } from '@serwist/next/worker'
|
||||
import { installSerwist } from 'serwist'
|
||||
declare const self: ServiceWorkerGlobalScope
|
||||
installSerwist({
|
||||
precacheEntries: self.__SW_MANIFEST,
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
navigationPreload: true,
|
||||
runtimeCaching: defaultCache,
|
||||
})
|
||||
```
|
||||
NOTE: Push event listeners will be added in Plan 03. Keep this minimal for now.
|
||||
|
||||
5. Update `next.config.ts` — wrap with Serwist:
|
||||
```typescript
|
||||
import withSerwistInit from '@serwist/next'
|
||||
const withSerwist = withSerwistInit({
|
||||
swSrc: 'app/sw.ts',
|
||||
swDest: 'public/sw.js',
|
||||
disable: process.env.NODE_ENV === 'development',
|
||||
})
|
||||
export default withNextIntl(withSerwist(nextConfig))
|
||||
```
|
||||
Compose order: withNextIntl wraps withSerwist wraps nextConfig.
|
||||
|
||||
6. Create `components/sw-register.tsx` — client component that registers the service worker:
|
||||
```typescript
|
||||
"use client"
|
||||
import { useEffect } from 'react'
|
||||
export function ServiceWorkerRegistration() {
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
void navigator.serviceWorker.register('/sw.js', { scope: '/', updateViaCache: 'none' })
|
||||
}
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
7. Update `app/layout.tsx`:
|
||||
- Add `Viewport` export with `viewportFit: 'cover'` to enable safe-area-inset CSS env vars on iOS
|
||||
- Mount `<ServiceWorkerRegistration />` in the body (outside NextIntlClientProvider is fine since it uses no translations)
|
||||
- Add `<OfflineBanner />` in body (inside NextIntlClientProvider since it needs translations)
|
||||
|
||||
8. Create `lib/use-offline.ts` — hook for online/offline state:
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react'
|
||||
export function useOnlineStatus() {
|
||||
const [isOnline, setIsOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true)
|
||||
useEffect(() => {
|
||||
const on = () => setIsOnline(true)
|
||||
const off = () => setIsOnline(false)
|
||||
window.addEventListener('online', on)
|
||||
window.addEventListener('offline', off)
|
||||
return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off) }
|
||||
}, [])
|
||||
return isOnline
|
||||
}
|
||||
```
|
||||
|
||||
9. Create `components/offline-banner.tsx` — fixed banner at top when offline:
|
||||
```typescript
|
||||
"use client"
|
||||
// Shows "You're offline" with a subtle amber/red bar at the top of the viewport
|
||||
// Uses useOnlineStatus hook. Renders null when online.
|
||||
// Position: fixed top-0 z-[60] full width, above everything
|
||||
// Add i18n key: common.offlineBanner
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npm run lint</automated>
|
||||
</verify>
|
||||
<done>
|
||||
PWA dependencies installed. Icon PNGs exist in public/. manifest.ts serves at /manifest.webmanifest. sw.ts compiles. Service worker registration component mounted in root layout. Viewport export includes viewportFit cover. Offline banner shows when navigator.onLine is false. Lint passes.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Mobile bottom tab bar, More sheet, and responsive dashboard layout</name>
|
||||
<files>
|
||||
packages/portal/app/(dashboard)/layout.tsx,
|
||||
packages/portal/components/mobile-nav.tsx,
|
||||
packages/portal/components/mobile-more-sheet.tsx,
|
||||
packages/portal/messages/en.json,
|
||||
packages/portal/messages/es.json,
|
||||
packages/portal/messages/pt.json
|
||||
</files>
|
||||
<action>
|
||||
1. Create `components/mobile-nav.tsx` — bottom tab bar:
|
||||
- "use client" component
|
||||
- Position: `fixed bottom-0 left-0 right-0 z-50 bg-background border-t`
|
||||
- Add `paddingBottom: env(safe-area-inset-bottom)` via inline style for iOS home indicator
|
||||
- CSS class: `md:hidden` — only visible below 768px
|
||||
- 5 tab items: Dashboard (LayoutDashboard icon, href="/dashboard"), Employees (Users icon, href="/agents"), Chat (MessageSquare icon, href="/chat"), Usage (BarChart2 icon, href="/usage"), More (MoreHorizontal or Ellipsis icon, opens sheet)
|
||||
- Active tab: highlighted with primary color icon + subtle indicator dot/pill below icon
|
||||
- Use `usePathname()` to determine active state
|
||||
- Icons should be the primary visual element with very small text labels below (per user: "solid icons, subtle active indicator, no text labels (or very small ones)")
|
||||
- RBAC: Read session role. The 4 navigation items (Dashboard, Employees, Chat, Usage) are visible to all roles. "More" is always visible. RBAC filtering happens inside the More sheet.
|
||||
- Tab bar height: ~60px plus safe area. Content padding `pb-16 md:pb-0` on main.
|
||||
|
||||
2. Create `components/mobile-more-sheet.tsx` — bottom sheet for secondary items:
|
||||
- "use client" component
|
||||
- Use @base-ui/react Dialog as a bottom sheet (position: fixed bottom, slides up with animation)
|
||||
- Contains nav links: Billing, API Keys, Users, Platform, Settings, Sign Out
|
||||
- Apply RBAC: filter items by role (same logic as Nav — Billing/API Keys/Users visible to platform_admin and customer_admin only; Platform visible to platform_admin only)
|
||||
- Include LanguageSwitcher component at the bottom of the sheet
|
||||
- Include Sign Out button at the bottom
|
||||
- Props: `open: boolean, onOpenChange: (open: boolean) => void`
|
||||
- Style: rounded-t-2xl, bg-background, max-h-[70vh], draggable handle at top
|
||||
- Each item: icon + label, full-width tap target, closes sheet on navigation
|
||||
|
||||
3. Update `app/(dashboard)/layout.tsx`:
|
||||
- Wrap existing `<Nav />` in `<div className="hidden md:flex">` — sidebar hidden on mobile
|
||||
- Add `<MobileNav />` after the main content area (it renders with md:hidden internally)
|
||||
- Add `pb-16 md:pb-0` to the `<main>` element to clear the tab bar on mobile
|
||||
- Reduce padding on mobile: change `px-8 py-8` to `px-4 md:px-8 py-4 md:py-8`
|
||||
- Keep all existing providers (SessionProvider, QueryClientProvider, etc.) unchanged
|
||||
|
||||
4. Add i18n keys for mobile nav in all three locale files (en.json, es.json, pt.json):
|
||||
- mobileNav.dashboard, mobileNav.employees, mobileNav.chat, mobileNav.usage, mobileNav.more
|
||||
- mobileNav.billing, mobileNav.apiKeys, mobileNav.users, mobileNav.platform, mobileNav.settings, mobileNav.signOut
|
||||
- common.offlineBanner: "You're offline — changes will sync when you reconnect"
|
||||
NOTE: Nav already has these keys under "nav.*" — reuse the same translation keys from the nav namespace where possible to avoid duplication. Only add new keys if the mobile label differs.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npm run build</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Bottom tab bar renders on screens below 768px with 5 items. Desktop sidebar is hidden on mobile. More sheet opens from the More tab with RBAC-filtered secondary items including language switcher and sign out. Main content has bottom padding on mobile. All pages render without layout breakage on both mobile and desktop. Build passes.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `npm run build` passes in packages/portal (TypeScript + Next.js compilation)
|
||||
- `npm run lint` passes in packages/portal
|
||||
- PWA manifest accessible (app/manifest.ts exports valid MetadataRoute.Manifest)
|
||||
- Icon files exist: public/icon-192.png, public/icon-512.png, public/icon-maskable-192.png, public/apple-touch-icon.png, public/badge-72.png
|
||||
- Service worker source compiles (app/sw.ts)
|
||||
- Desktop layout unchanged — sidebar visible at md+ breakpoint
|
||||
- Mobile layout shows bottom tab bar, sidebar hidden
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
All portal pages render with the bottom tab bar on mobile (< 768px) and the sidebar on desktop (>= 768px). PWA manifest and service worker infrastructure are in place. Offline banner appears when disconnected.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/08-mobile-pwa/08-01-SUMMARY.md`
|
||||
</output>
|
||||
239
.planning/phases/08-mobile-pwa/08-02-PLAN.md
Normal file
239
.planning/phases/08-mobile-pwa/08-02-PLAN.md
Normal file
@@ -0,0 +1,239 @@
|
||||
---
|
||||
phase: 08-mobile-pwa
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- packages/portal/app/(dashboard)/chat/page.tsx
|
||||
- packages/portal/components/chat-window.tsx
|
||||
- packages/portal/components/chat-sidebar.tsx
|
||||
- packages/portal/components/mobile-chat-header.tsx
|
||||
- packages/portal/lib/use-visual-viewport.ts
|
||||
- packages/portal/lib/use-chat-socket.ts
|
||||
- packages/portal/messages/en.json
|
||||
- packages/portal/messages/es.json
|
||||
- packages/portal/messages/pt.json
|
||||
autonomous: true
|
||||
requirements:
|
||||
- MOB-03
|
||||
- MOB-06
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "On mobile, tapping a conversation shows full-screen chat with back arrow header"
|
||||
- "Back arrow returns to conversation list on mobile"
|
||||
- "Desktop two-column chat layout is unchanged"
|
||||
- "Chat input stays visible when iOS virtual keyboard opens"
|
||||
- "Message input is fixed at bottom, does not scroll away"
|
||||
- "Streaming responses (word-by-word tokens) work on mobile"
|
||||
- "No hover-dependent interactions break on touch devices"
|
||||
artifacts:
|
||||
- path: "packages/portal/components/mobile-chat-header.tsx"
|
||||
provides: "Back arrow + agent name header for mobile full-screen chat"
|
||||
exports: ["MobileChatHeader"]
|
||||
- path: "packages/portal/lib/use-visual-viewport.ts"
|
||||
provides: "Visual Viewport API hook for iOS keyboard offset"
|
||||
exports: ["useVisualViewport"]
|
||||
key_links:
|
||||
- from: "packages/portal/app/(dashboard)/chat/page.tsx"
|
||||
to: "packages/portal/components/mobile-chat-header.tsx"
|
||||
via: "rendered when mobileShowChat is true on < md screens"
|
||||
pattern: "MobileChatHeader"
|
||||
- from: "packages/portal/components/chat-window.tsx"
|
||||
to: "packages/portal/lib/use-visual-viewport.ts"
|
||||
via: "keyboard offset applied to input container"
|
||||
pattern: "useVisualViewport"
|
||||
- from: "packages/portal/app/(dashboard)/chat/page.tsx"
|
||||
to: "mobileShowChat state"
|
||||
via: "toggles between conversation list and full-screen chat on mobile"
|
||||
pattern: "mobileShowChat"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Mobile-optimized chat experience with WhatsApp-style full-screen conversation flow, iOS keyboard handling, and touch-safe interactions.
|
||||
|
||||
Purpose: Chat is the primary user interaction on mobile. The two-column desktop layout doesn't work on small screens. This plan implements the conversation list -> full-screen chat pattern (like WhatsApp/iMessage) and handles the iOS virtual keyboard problem that breaks fixed inputs.
|
||||
|
||||
Output: Full-screen mobile chat with back navigation, Visual Viewport keyboard handling, touch-safe interaction patterns.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/08-mobile-pwa/08-CONTEXT.md
|
||||
@.planning/phases/08-mobile-pwa/08-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. -->
|
||||
|
||||
From packages/portal/app/(dashboard)/chat/page.tsx:
|
||||
```typescript
|
||||
// ChatPageInner renders:
|
||||
// - <div className="w-72 shrink-0"> with <ChatSidebar ... />
|
||||
// - <div className="flex-1"> with <ChatWindow ... />
|
||||
// State: activeConversationId, showAgentPicker
|
||||
// handleSelectConversation sets activeConversationId + updates URL
|
||||
// Container: <div className="flex h-[calc(100vh-4rem)] overflow-hidden">
|
||||
```
|
||||
|
||||
From packages/portal/components/chat-window.tsx:
|
||||
```typescript
|
||||
export interface ChatWindowProps {
|
||||
conversationId: string | null;
|
||||
authHeaders: ChatSocketAuthHeaders;
|
||||
}
|
||||
// ActiveConversation renders:
|
||||
// - Connection status banner
|
||||
// - Message list: <div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
// - Input area: <div className="shrink-0 border-t px-4 py-3">
|
||||
// Container: <div className="flex flex-col h-full">
|
||||
```
|
||||
|
||||
From packages/portal/components/chat-sidebar.tsx:
|
||||
```typescript
|
||||
export interface ChatSidebarProps {
|
||||
conversations: Conversation[];
|
||||
activeId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onNewChat: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
From packages/portal/lib/use-chat-socket.ts:
|
||||
```typescript
|
||||
export type ChatSocketAuthHeaders = {
|
||||
userId: string;
|
||||
role: string;
|
||||
tenantId: string | null;
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Mobile full-screen chat toggle and Visual Viewport keyboard hook</name>
|
||||
<files>
|
||||
packages/portal/app/(dashboard)/chat/page.tsx,
|
||||
packages/portal/components/chat-window.tsx,
|
||||
packages/portal/components/chat-sidebar.tsx,
|
||||
packages/portal/components/mobile-chat-header.tsx,
|
||||
packages/portal/lib/use-visual-viewport.ts
|
||||
</files>
|
||||
<action>
|
||||
1. Create `lib/use-visual-viewport.ts` — hook to handle iOS keyboard offset:
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react'
|
||||
export function useVisualViewport() {
|
||||
const [offset, setOffset] = useState(0)
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport
|
||||
if (!vv) return
|
||||
const handler = () => {
|
||||
const diff = window.innerHeight - vv.height - vv.offsetTop
|
||||
setOffset(Math.max(0, diff))
|
||||
}
|
||||
vv.addEventListener('resize', handler)
|
||||
vv.addEventListener('scroll', handler)
|
||||
return () => { vv.removeEventListener('resize', handler); vv.removeEventListener('scroll', handler) }
|
||||
}, [])
|
||||
return offset
|
||||
}
|
||||
```
|
||||
|
||||
2. Create `components/mobile-chat-header.tsx`:
|
||||
- "use client" component
|
||||
- Props: `agentName: string, onBack: () => void`
|
||||
- Renders: `md:hidden` — only visible on mobile
|
||||
- Layout: flex row with ArrowLeft icon button (onBack), agent avatar circle (first letter of agentName), agent name text
|
||||
- Style: sticky top-0 z-10 bg-background border-b, h-14, items centered
|
||||
- Back arrow: large tap target (min 44x44px), uses lucide ArrowLeft
|
||||
|
||||
3. Update `app/(dashboard)/chat/page.tsx` — add mobile full-screen toggle:
|
||||
- Add state: `const [mobileShowChat, setMobileShowChat] = useState(false)`
|
||||
- Modify `handleSelectConversation` to also call `setMobileShowChat(true)` (always, not just on mobile — CSS handles visibility)
|
||||
- Update container: change `h-[calc(100vh-4rem)]` to `h-[calc(100dvh-4rem)] md:h-[calc(100vh-4rem)]` (dvh for mobile to handle iOS browser chrome)
|
||||
- Chat sidebar panel: wrap with conditional classes:
|
||||
```tsx
|
||||
<div className={cn(
|
||||
"md:w-72 md:shrink-0 md:block",
|
||||
mobileShowChat ? "hidden" : "flex flex-col w-full"
|
||||
)}>
|
||||
```
|
||||
- Chat window panel: wrap with conditional classes:
|
||||
```tsx
|
||||
<div className={cn(
|
||||
"flex-1 md:block",
|
||||
!mobileShowChat ? "hidden" : "flex flex-col w-full"
|
||||
)}>
|
||||
{mobileShowChat && (
|
||||
<MobileChatHeader
|
||||
agentName={activeConversationAgentName}
|
||||
onBack={() => setMobileShowChat(false)}
|
||||
/>
|
||||
)}
|
||||
<ChatWindow ... />
|
||||
</div>
|
||||
```
|
||||
- Extract agent name from conversations array for the active conversation: `const activeConversationAgentName = conversations.find(c => c.id === activeConversationId)?.agent_name ?? 'AI Employee'`
|
||||
- When URL has `?id=xxx` on mount and on mobile, set mobileShowChat to true:
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
if (urlConversationId) setMobileShowChat(true)
|
||||
}, [urlConversationId])
|
||||
```
|
||||
- On mobile, the "New Chat" agent picker should also set mobileShowChat true after conversation creation (already handled by handleSelectConversation calling setMobileShowChat(true))
|
||||
|
||||
4. Update `components/chat-window.tsx` — keyboard-safe input on mobile:
|
||||
- Import and use `useVisualViewport` in ActiveConversation
|
||||
- Apply keyboard offset to the input container:
|
||||
```tsx
|
||||
const keyboardOffset = useVisualViewport()
|
||||
// On the input area div:
|
||||
<div className="shrink-0 border-t px-4 py-3"
|
||||
style={{ paddingBottom: `calc(${keyboardOffset}px + env(safe-area-inset-bottom, 0px))` }}>
|
||||
```
|
||||
- When keyboardOffset > 0 (keyboard is open), auto-scroll to bottom of message list
|
||||
- Change the EmptyState container from `h-full` to responsive: works both when full-screen and when sharing space
|
||||
|
||||
5. Update `components/chat-sidebar.tsx` — touch-optimized tap targets:
|
||||
- Ensure conversation buttons have minimum 44px height (current py-3 likely sufficient, verify)
|
||||
- The "New Conversation" button should have at least 44x44 tap target on mobile
|
||||
- Replace any `hover:bg-accent` with `hover:bg-accent active:bg-accent` so touch devices get immediate feedback via the active pseudo-class (Tailwind v4 wraps hover in @media(hover:hover) already, but active provides touch feedback)
|
||||
|
||||
6. Add i18n key: `chat.backToConversations` in en/es/pt.json for the back button aria-label
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npm run build</automated>
|
||||
</verify>
|
||||
<done>
|
||||
On mobile (< 768px): chat page shows conversation list full-width. Tapping a conversation shows full-screen chat with back arrow header. Back arrow returns to list. iOS keyboard pushes the input up instead of hiding it. Desktop two-column layout unchanged. Build passes. All chat functionality (send, streaming, typing indicator) works in both layouts.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `npm run build` passes in packages/portal
|
||||
- Chat page renders conversation list on mobile by default
|
||||
- Selecting a conversation shows full-screen chat with MobileChatHeader on mobile
|
||||
- Back button returns to conversation list
|
||||
- Desktop layout unchanged (two columns)
|
||||
- Chat input stays visible when keyboard opens (Visual Viewport hook active)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Mobile chat follows the WhatsApp-style pattern: conversation list full-screen, then full-screen chat with back arrow. Input is keyboard-safe on iOS. Touch interactions have immediate feedback. Desktop layout is unmodified.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/08-mobile-pwa/08-02-SUMMARY.md`
|
||||
</output>
|
||||
335
.planning/phases/08-mobile-pwa/08-03-PLAN.md
Normal file
335
.planning/phases/08-mobile-pwa/08-03-PLAN.md
Normal file
@@ -0,0 +1,335 @@
|
||||
---
|
||||
phase: 08-mobile-pwa
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "08-01"
|
||||
files_modified:
|
||||
- packages/portal/app/sw.ts
|
||||
- packages/portal/components/install-prompt.tsx
|
||||
- packages/portal/components/push-permission.tsx
|
||||
- packages/portal/lib/message-queue.ts
|
||||
- packages/portal/lib/use-chat-socket.ts
|
||||
- packages/portal/app/(dashboard)/layout.tsx
|
||||
- packages/portal/app/actions/push.ts
|
||||
- packages/gateway/routers/push.py
|
||||
- packages/gateway/main.py
|
||||
- packages/shared/models/push.py
|
||||
- migrations/versions/010_push_subscriptions.py
|
||||
- packages/portal/messages/en.json
|
||||
- packages/portal/messages/es.json
|
||||
- packages/portal/messages/pt.json
|
||||
autonomous: true
|
||||
requirements:
|
||||
- MOB-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can grant push notification permission from the portal"
|
||||
- "Push subscription is stored in the database (not just memory)"
|
||||
- "When an AI Employee responds and the user's WebSocket is not connected, a push notification is sent"
|
||||
- "Tapping a push notification opens the relevant conversation in the portal"
|
||||
- "PWA install prompt appears on second visit (not first) and is dismissable"
|
||||
- "Offline message queue stores unsent messages in IndexedDB and drains on reconnection"
|
||||
- "Stale push subscriptions (410 Gone) are deleted from the database"
|
||||
artifacts:
|
||||
- path: "packages/portal/components/install-prompt.tsx"
|
||||
provides: "Smart install banner for PWA (second visit)"
|
||||
exports: ["InstallPrompt"]
|
||||
- path: "packages/portal/components/push-permission.tsx"
|
||||
provides: "Push notification opt-in UI"
|
||||
exports: ["PushPermission"]
|
||||
- path: "packages/portal/lib/message-queue.ts"
|
||||
provides: "IndexedDB offline message queue"
|
||||
exports: ["enqueueMessage", "drainQueue"]
|
||||
- path: "packages/portal/app/actions/push.ts"
|
||||
provides: "Server actions for push subscription management and sending"
|
||||
- path: "packages/gateway/routers/push.py"
|
||||
provides: "Push subscription CRUD API endpoints"
|
||||
- path: "migrations/versions/010_push_subscriptions.py"
|
||||
provides: "push_subscriptions table migration"
|
||||
key_links:
|
||||
- from: "packages/portal/app/sw.ts"
|
||||
to: "push event handler"
|
||||
via: "self.addEventListener('push', ...) shows notification"
|
||||
pattern: "addEventListener.*push"
|
||||
- from: "packages/portal/app/sw.ts"
|
||||
to: "notificationclick handler"
|
||||
via: "self.addEventListener('notificationclick', ...) opens conversation"
|
||||
pattern: "notificationclick"
|
||||
- from: "packages/gateway/routers/push.py"
|
||||
to: "packages/shared/models/push.py"
|
||||
via: "stores PushSubscription in DB"
|
||||
pattern: "push_subscriptions"
|
||||
- from: "packages/portal/lib/use-chat-socket.ts"
|
||||
to: "packages/portal/lib/message-queue.ts"
|
||||
via: "enqueue when offline, drain on reconnect"
|
||||
pattern: "enqueueMessage|drainQueue"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Push notifications, offline message queue, and PWA install prompt. Users receive notifications when AI Employees respond, messages queue offline and auto-send on reconnection, and the install banner appears on the second visit.
|
||||
|
||||
Purpose: Push notifications make the platform feel alive on mobile — users know immediately when their AI Employee responds. Offline message queue prevents lost messages. The install prompt drives PWA adoption.
|
||||
|
||||
Output: Working push notification pipeline (client subscription -> DB storage -> server-side send -> service worker display), IndexedDB message queue with auto-drain, second-visit install banner.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/08-mobile-pwa/08-CONTEXT.md
|
||||
@.planning/phases/08-mobile-pwa/08-RESEARCH.md
|
||||
@.planning/phases/08-mobile-pwa/08-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts from prior plans and existing code -->
|
||||
|
||||
From packages/portal/app/sw.ts (created in Plan 01):
|
||||
```typescript
|
||||
// Serwist service worker with installSerwist()
|
||||
// Push event listener to be added here
|
||||
```
|
||||
|
||||
From packages/portal/lib/use-chat-socket.ts:
|
||||
```typescript
|
||||
export interface UseChatSocketOptions {
|
||||
conversationId: string;
|
||||
authHeaders: ChatSocketAuthHeaders;
|
||||
onMessage: (text: string) => void;
|
||||
onTyping: (typing: boolean) => void;
|
||||
onChunk: (token: string) => void;
|
||||
onDone: (fullText: string) => void;
|
||||
}
|
||||
export function useChatSocket(options: UseChatSocketOptions): { send: (text: string) => void; isConnected: boolean }
|
||||
// WebSocket connects to gateway at WS_URL/ws/chat
|
||||
// Uses refs for callbacks to avoid reconnection on handler changes
|
||||
```
|
||||
|
||||
From packages/gateway/main.py:
|
||||
```python
|
||||
# FastAPI app with routers mounted for portal, billing, channels, llm_keys, usage, webhook
|
||||
# New push router needs to be mounted here
|
||||
```
|
||||
|
||||
From packages/shared/models/:
|
||||
```python
|
||||
# Pydantic models and SQLAlchemy ORM models
|
||||
# New PushSubscription model goes here
|
||||
```
|
||||
|
||||
From migrations/versions/:
|
||||
```python
|
||||
# Alembic migrations — latest is 009_*
|
||||
# Next migration: 010_push_subscriptions
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Push notification backend — DB migration, API endpoints, VAPID setup</name>
|
||||
<files>
|
||||
packages/shared/models/push.py,
|
||||
migrations/versions/010_push_subscriptions.py,
|
||||
packages/gateway/routers/push.py,
|
||||
packages/gateway/main.py,
|
||||
packages/portal/messages/en.json,
|
||||
packages/portal/messages/es.json,
|
||||
packages/portal/messages/pt.json
|
||||
</files>
|
||||
<action>
|
||||
1. Generate VAPID keys:
|
||||
```bash
|
||||
cd packages/portal && npx web-push generate-vapid-keys
|
||||
```
|
||||
Add to `.env.example` and `.env`:
|
||||
- `NEXT_PUBLIC_VAPID_PUBLIC_KEY=...`
|
||||
- `VAPID_PRIVATE_KEY=...`
|
||||
|
||||
2. Create `packages/shared/models/push.py`:
|
||||
- SQLAlchemy ORM model `PushSubscription`:
|
||||
- id: UUID primary key (server_default=gen_random_uuid())
|
||||
- user_id: UUID, NOT NULL, FK to portal_users.id
|
||||
- tenant_id: UUID, nullable (for scoping notifications)
|
||||
- endpoint: TEXT, NOT NULL (push service URL)
|
||||
- p256dh: TEXT, NOT NULL (encryption key)
|
||||
- auth: TEXT, NOT NULL (auth secret)
|
||||
- created_at: TIMESTAMP WITH TIME ZONE, server_default=now()
|
||||
- updated_at: TIMESTAMP WITH TIME ZONE, server_default=now(), onupdate=now()
|
||||
- Unique constraint on (user_id, endpoint) — one subscription per browser per user
|
||||
- RLS policy: users can only read/write their own subscriptions
|
||||
- Pydantic schema: PushSubscriptionCreate(endpoint, p256dh, auth), PushSubscriptionOut(id, endpoint, created_at)
|
||||
|
||||
3. Create `migrations/versions/010_push_subscriptions.py`:
|
||||
- Alembic migration creating the push_subscriptions table
|
||||
- Add RLS: ENABLE ROW LEVEL SECURITY, FORCE ROW LEVEL SECURITY
|
||||
- RLS policy: user_id = current_setting('app.current_user')::uuid (or use the same tenant-based RLS pattern as other tables)
|
||||
- Actually — push subscriptions are per-user not per-tenant, so the API should filter by user_id in the query, not rely on tenant RLS. Add a simple policy or skip RLS for this table and filter in the application layer (since it's portal-user-scoped, not tenant-scoped).
|
||||
|
||||
4. Create `packages/gateway/routers/push.py`:
|
||||
- POST /portal/push/subscribe — stores push subscription for authenticated user
|
||||
- Body: { endpoint, keys: { p256dh, auth } }
|
||||
- Upserts by (user_id, endpoint)
|
||||
- Returns 201
|
||||
- DELETE /portal/push/unsubscribe — removes subscription by endpoint
|
||||
- Body: { endpoint }
|
||||
- Returns 204
|
||||
- POST /portal/push/send — internal endpoint (called by orchestrator/gateway when agent responds)
|
||||
- Body: { user_id, title, body, conversation_id }
|
||||
- Looks up all push subscriptions for user_id
|
||||
- Sends via web-push library (Python equivalent: pywebpush)
|
||||
- Handles 410 Gone by deleting stale subscriptions
|
||||
- Returns 200
|
||||
- For the push send: install `pywebpush` in gateway's dependencies. Actually, since the push send needs to happen from the backend (Python), use `pywebpush` not the Node `web-push`. Add `pywebpush` to gateway's pyproject.toml.
|
||||
- Alternatively: the push send can happen from the portal (Node.js) via a Server Action. The gateway can call the portal's API or the portal can subscribe to the same Redis pub-sub channel.
|
||||
- SIMPLEST APPROACH per research: The gateway WebSocket handler already checks if a client is connected. When the orchestrator task publishes the response to Redis, the gateway WS handler receives it. If no active WebSocket session exists for that user+conversation, trigger a push notification. The push send itself should use pywebpush from the gateway since that's where the event originates.
|
||||
|
||||
5. Mount push router in `packages/gateway/main.py`:
|
||||
```python
|
||||
from routers.push import router as push_router
|
||||
app.include_router(push_router)
|
||||
```
|
||||
|
||||
6. Add `pywebpush` to gateway's pyproject.toml dependencies.
|
||||
|
||||
7. Wire push notification trigger into the gateway's WebSocket response handler:
|
||||
- In the existing WebSocket handler (where it publishes agent responses to the client), add logic:
|
||||
- After receiving agent response from Redis pub-sub, check if the WebSocket for that user is still connected
|
||||
- If NOT connected, call the push send logic (or fire a Celery task) with the user_id, conversation_id, agent response preview
|
||||
- Use VAPID_PRIVATE_KEY and NEXT_PUBLIC_VAPID_PUBLIC_KEY from environment
|
||||
|
||||
8. Add i18n keys for push notifications:
|
||||
- push.enableNotifications, push.enableNotificationsDescription, push.enabled, push.denied, push.notSupported
|
||||
- install.title, install.description, install.installButton, install.dismissButton, install.iosInstructions
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct && python -m py_compile packages/gateway/routers/push.py && python -m py_compile packages/shared/models/push.py</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Push subscriptions table exists in migration. Gateway has push API endpoints (subscribe, unsubscribe, send). pywebpush integrated for server-side notification delivery. Push trigger wired into WebSocket response handler — sends notification when user is not connected. VAPID keys in env.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Push subscription client, service worker push handler, install prompt, offline queue</name>
|
||||
<files>
|
||||
packages/portal/app/sw.ts,
|
||||
packages/portal/components/install-prompt.tsx,
|
||||
packages/portal/components/push-permission.tsx,
|
||||
packages/portal/lib/message-queue.ts,
|
||||
packages/portal/lib/use-chat-socket.ts,
|
||||
packages/portal/app/(dashboard)/layout.tsx
|
||||
</files>
|
||||
<action>
|
||||
1. Update `app/sw.ts` — add push event handlers:
|
||||
```typescript
|
||||
// After installSerwist(...)
|
||||
self.addEventListener('push', (event) => {
|
||||
const data = event.data?.json()
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/badge-72.png',
|
||||
data: data.data, // { conversationId }
|
||||
vibrate: [100, 50, 100],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
const conversationId = event.notification.data?.conversationId
|
||||
const url = conversationId ? `/chat?id=${conversationId}` : '/chat'
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
||||
// Focus existing window if open
|
||||
for (const client of windowClients) {
|
||||
if (client.url.includes('/chat') && 'focus' in client) {
|
||||
client.navigate(url)
|
||||
return client.focus()
|
||||
}
|
||||
}
|
||||
// Open new window
|
||||
return clients.openWindow(url)
|
||||
})
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
2. Create `components/push-permission.tsx`:
|
||||
- "use client" component
|
||||
- Shows a card/banner prompting the user to enable push notifications
|
||||
- On click: requests Notification.permission, subscribes via PushManager with VAPID public key, POSTs subscription to /portal/push/subscribe
|
||||
- States: 'default' (show prompt), 'granted' (show "enabled" badge), 'denied' (show "blocked" message with instructions)
|
||||
- Handles browsers that don't support push: show "not supported" message
|
||||
- Uses `process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY` for applicationServerKey
|
||||
- Utility: `urlBase64ToUint8Array(base64String)` helper for converting VAPID key
|
||||
- Place this component in the More sheet or in a settings section — not intrusive
|
||||
|
||||
3. Create `components/install-prompt.tsx`:
|
||||
- "use client" component
|
||||
- Captures `beforeinstallprompt` event on mount, stores in ref
|
||||
- Tracks visit count via localStorage ('konstruct_visit_count')
|
||||
- Shows install banner only when: visit count >= 2 AND not already in standalone mode AND prompt is available (Android/Chrome) OR is iOS
|
||||
- For iOS: detect via userAgent, show instructions "Tap Share icon, then Add to Home Screen"
|
||||
- Dismiss button: sets localStorage 'konstruct_install_dismissed' = 'true'
|
||||
- Check `window.matchMedia('(display-mode: standalone)').matches` — if already installed, never show
|
||||
- Style: fixed bottom-20 (above tab bar) left/right margin, rounded card with app icon, text, install button, dismiss X
|
||||
- On install click: call deferredPrompt.prompt(), await userChoice
|
||||
|
||||
4. Create `lib/message-queue.ts` — IndexedDB offline queue using `idb`:
|
||||
```typescript
|
||||
import { openDB } from 'idb'
|
||||
const DB_NAME = 'konstruct-offline'
|
||||
const STORE = 'message-queue'
|
||||
export async function enqueueMessage(conversationId: string, text: string) { ... }
|
||||
export async function drainQueue(send: (convId: string, text: string) => void) { ... }
|
||||
```
|
||||
|
||||
5. Update `lib/use-chat-socket.ts` — integrate offline queue:
|
||||
- Import enqueueMessage and drainQueue from message-queue
|
||||
- In the `send` function: if WebSocket is not connected (isConnected is false), call `enqueueMessage(conversationId, text)` instead of sending via WebSocket
|
||||
- On reconnection (when WebSocket opens): call `drainQueue((convId, text) => ws.send(...))` to send queued messages
|
||||
- Add `useOnlineStatus` check — when transitioning from offline to online, trigger reconnection
|
||||
|
||||
6. Mount `<InstallPrompt />` and `<PushPermission />` in `app/(dashboard)/layout.tsx`:
|
||||
- InstallPrompt: rendered at the bottom of the layout (above MobileNav)
|
||||
- PushPermission: rendered inside the More sheet or as a one-time prompt after first login
|
||||
- Actually, a simpler approach: add a "Notifications" toggle in the More sheet that triggers push permission. The PushPermission component can be a button within the MobileMoreSheet.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npm run build</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Service worker handles push events and shows notifications with conversation deep-link. Push permission UI available in the portal. Install prompt appears on second visit and is dismissable. Offline message queue stores messages in IndexedDB and auto-drains on reconnection. Build passes.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `npm run build` passes in packages/portal
|
||||
- Python files compile without errors
|
||||
- Service worker source (app/sw.ts) includes push and notificationclick handlers
|
||||
- Push subscription API endpoints registered on gateway
|
||||
- Migration 010 creates push_subscriptions table
|
||||
- Install prompt component handles both Android (beforeinstallprompt) and iOS (manual instructions)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Push notifications are delivered when the user's PWA is not in the foreground. Tapping a notification opens the conversation. Offline messages queue in IndexedDB and send on reconnection. Install prompt shows on second visit, not first.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/08-mobile-pwa/08-03-SUMMARY.md`
|
||||
</output>
|
||||
143
.planning/phases/08-mobile-pwa/08-04-PLAN.md
Normal file
143
.planning/phases/08-mobile-pwa/08-04-PLAN.md
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
phase: 08-mobile-pwa
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on:
|
||||
- "08-01"
|
||||
- "08-02"
|
||||
- "08-03"
|
||||
files_modified: []
|
||||
autonomous: false
|
||||
requirements:
|
||||
- MOB-01
|
||||
- MOB-02
|
||||
- MOB-03
|
||||
- MOB-04
|
||||
- MOB-05
|
||||
- MOB-06
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "All portal pages render correctly on mobile (320px) and tablet (768px) viewports"
|
||||
- "Bottom tab bar navigation works with RBAC filtering"
|
||||
- "Chat full-screen flow works end-to-end on mobile"
|
||||
- "PWA is installable and shows correct icon/splash"
|
||||
- "Push notifications received on installed PWA"
|
||||
- "No hover-stuck interactions on touch devices"
|
||||
artifacts: []
|
||||
key_links: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
Human verification of all mobile and PWA features across device viewports and interaction modes.
|
||||
|
||||
Purpose: Mobile UX cannot be fully validated by automated tests. This checkpoint confirms that the responsive layout, touch interactions, PWA install, and push notifications work correctly on real device viewports.
|
||||
|
||||
Output: Human-verified sign-off that all MOB requirements are met.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/08-mobile-pwa/08-CONTEXT.md
|
||||
@.planning/phases/08-mobile-pwa/08-01-SUMMARY.md
|
||||
@.planning/phases/08-mobile-pwa/08-02-SUMMARY.md
|
||||
@.planning/phases/08-mobile-pwa/08-03-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 1: Verify all mobile and PWA features</name>
|
||||
<files></files>
|
||||
<action>
|
||||
Present the verification checklist to the user. All implementation was completed in Plans 01-03. This task verifies the results.
|
||||
|
||||
What was built:
|
||||
- Bottom tab bar navigation (5 items) with More bottom sheet on mobile
|
||||
- Desktop sidebar preserved on screens >= 768px
|
||||
- Full-screen WhatsApp-style chat flow on mobile
|
||||
- iOS virtual keyboard handling for chat input
|
||||
- PWA manifest with K monogram icons
|
||||
- Service worker with offline caching
|
||||
- Push notifications for AI Employee responses
|
||||
- Offline message queue with auto-drain
|
||||
- Second-visit install prompt
|
||||
- Offline status banner
|
||||
|
||||
How to verify:
|
||||
|
||||
**Setup:** Start the portal with `npm run dev` (or `npm run dev:pwa` with `--experimental-https --webpack` for full PWA testing).
|
||||
|
||||
**MOB-01 — Responsive pages (all viewports):**
|
||||
1. Open Chrome DevTools, toggle device toolbar
|
||||
2. Test at 320px width (iPhone SE): Dashboard, Employees, Chat, Usage, Billing pages
|
||||
3. Test at 768px width (iPad): same pages
|
||||
4. Test at 1024px width (iPad landscape): same pages
|
||||
5. Verify: no horizontal scrolling, no overlapping elements, readable text
|
||||
|
||||
**MOB-02 — Mobile navigation:**
|
||||
1. At 320px width: verify bottom tab bar with 5 icons (Dashboard, Employees, Chat, Usage, More)
|
||||
2. Tap each tab — correct page loads, active indicator shows
|
||||
3. Tap "More" — bottom sheet slides up with Billing, API Keys, Users, etc.
|
||||
4. Test with operator role: verify restricted items hidden in More sheet
|
||||
5. At 768px+: verify sidebar appears, no tab bar
|
||||
|
||||
**MOB-03 — Mobile chat:**
|
||||
1. At 320px: navigate to Chat
|
||||
2. Verify conversation list shows full-width
|
||||
3. Tap a conversation: verify full-screen chat with back arrow + agent name header
|
||||
4. Send a message — verify it appears
|
||||
5. Wait for AI response — verify streaming tokens appear word-by-word
|
||||
6. Tap back arrow — verify return to conversation list
|
||||
7. Start a new conversation — verify agent picker works on mobile
|
||||
|
||||
**MOB-04 — PWA install:**
|
||||
1. Run with `npm run dev:pwa` (--experimental-https --webpack)
|
||||
2. Open Chrome DevTools > Application > Manifest: verify manifest loads with correct name, icons
|
||||
3. Application > Service Workers: verify SW registered
|
||||
4. Run Lighthouse PWA audit: target score >= 90
|
||||
5. If on Android Chrome: verify install prompt appears on second visit
|
||||
|
||||
**MOB-05 — Push notifications:**
|
||||
1. Enable notifications when prompted
|
||||
2. Open a chat conversation, send a message, get a response (verify WebSocket works)
|
||||
3. Close the browser tab / switch away
|
||||
4. Trigger another AI response (e.g., via a second browser window or API call)
|
||||
5. Verify push notification appears on device
|
||||
6. Tap notification — verify it opens the correct conversation
|
||||
|
||||
**MOB-06 — Touch interactions:**
|
||||
1. At 320px, tap all buttons and links — verify immediate visual feedback (no hover-stuck states)
|
||||
2. Verify no tooltips or dropdowns that require hover to trigger
|
||||
3. Verify all tap targets are >= 44px minimum dimension
|
||||
|
||||
Resume signal: Type "approved" to complete Phase 8, or describe issues to address.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npm run build</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Human confirms all six MOB requirements pass on mobile viewports. Lighthouse PWA audit score >= 90. No hover-stuck interactions on touch. Phase 8 complete.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
All MOB requirements verified by human testing on mobile viewports.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Human confirms all six MOB requirements pass on mobile viewports. Lighthouse PWA audit score >= 90.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/08-mobile-pwa/08-04-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user