docs(08-mobile-pwa): create phase plan

This commit is contained in:
2026-03-25 20:34:24 -06:00
parent 467a994d9f
commit d9b022bd4c
5 changed files with 1077 additions and 6 deletions

View File

@@ -142,7 +142,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8
| 5. Employee Design | 4/4 | Complete | 2026-03-25 | | 5. Employee Design | 4/4 | Complete | 2026-03-25 |
| 6. Web Chat | 3/3 | Complete | 2026-03-25 | | 6. Web Chat | 3/3 | Complete | 2026-03-25 |
| 7. Multilanguage | 4/4 | Complete | 2026-03-25 | | 7. Multilanguage | 4/4 | Complete | 2026-03-25 |
| 8. Mobile + PWA | 0/0 | Not started | - | | 8. Mobile + PWA | 0/4 | In progress | - |
--- ---
@@ -174,17 +174,20 @@ Plans:
**Depends on**: Phase 7 **Depends on**: Phase 7
**Requirements**: MOB-01, MOB-02, MOB-03, MOB-04, MOB-05, MOB-06 **Requirements**: MOB-01, MOB-02, MOB-03, MOB-04, MOB-05, MOB-06
**Success Criteria** (what must be TRUE): **Success Criteria** (what must be TRUE):
1. All portal pages render correctly and are usable on mobile screens (320px480px) and tablets (768px1024px) 1. All portal pages render correctly and are usable on mobile screens (320px-480px) and tablets (768px-1024px)
2. The sidebar collapses to a hamburger menu on mobile with smooth open/close animation 2. The sidebar collapses to a bottom tab bar on mobile with smooth open/close animation
3. The chat interface is fully functional on mobile — send messages, see streaming responses, scroll history 3. The chat interface is fully functional on mobile — send messages, see streaming responses, scroll history
4. The portal can be installed as a PWA from Chrome/Safari with app icon, splash screen, and offline shell 4. The portal can be installed as a PWA from Chrome/Safari with app icon, splash screen, and offline shell
5. Push notifications work for new messages when the PWA is installed (or at minimum, the service worker caches the app shell for instant load) 5. Push notifications work for new messages when the PWA is installed (or at minimum, the service worker caches the app shell for instant load)
6. All touch interactions (swipe, tap, long-press) feel native — no hover-dependent UI that breaks on touch 6. All touch interactions (swipe, tap, long-press) feel native — no hover-dependent UI that breaks on touch
**Plans**: 0 plans **Plans**: 4 plans
Plans: Plans:
- [ ] TBD (run /gsd:plan-phase 8 to break down) - [ ] 08-01-PLAN.md — PWA infrastructure (manifest, service worker, icons, offline banner) + responsive layout (bottom tab bar, More sheet, layout split)
- [ ] 08-02-PLAN.md — Mobile chat (full-screen WhatsApp-style flow, Visual Viewport keyboard handling, touch-safe interactions)
- [ ] 08-03-PLAN.md — Push notifications (VAPID, push subscription DB, service worker push handler, offline message queue, install prompt)
- [ ] 08-04-PLAN.md — Human verification: mobile responsive layout, PWA install, push notifications, touch interactions
--- ---
*Roadmap created: 2026-03-23* *Roadmap created: 2026-03-23*
*Coverage: 25/25 v1 requirements + 6 RBAC requirements + 5 Employee Design requirements + 5 Web Chat requirements + 6 Multilanguage requirements mapped* *Coverage: 25/25 v1 requirements + 6 RBAC requirements + 5 Employee Design requirements + 5 Web Chat requirements + 6 Multilanguage requirements + 6 Mobile+PWA requirements mapped*

View 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>

View 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>

View 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>

View 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>