+ ```
+ - Chat window panel: wrap with conditional classes:
+ ```tsx
+
+ ```
+ - 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:
+
+ ```
+ - 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
+
+
+ cd /home/adelorenzo/repos/konstruct/packages/portal && npm run build
+
+
+ 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.
+
+
+
+
+
+
+- `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)
+
+
+
+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.
+
+
+
diff --git a/.planning/phases/08-mobile-pwa/08-03-PLAN.md b/.planning/phases/08-mobile-pwa/08-03-PLAN.md
new file mode 100644
index 0000000..b7eb44c
--- /dev/null
+++ b/.planning/phases/08-mobile-pwa/08-03-PLAN.md
@@ -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"
+---
+
+
+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.
+
+
+
+@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
+@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
+
+
+
+@.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
+
+
+
+
+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
+```
+
+
+
+
+
+
+ Task 1: Push notification backend — DB migration, API endpoints, VAPID setup
+
+ 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
+
+
+ 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
+
+
+ cd /home/adelorenzo/repos/konstruct && python -m py_compile packages/gateway/routers/push.py && python -m py_compile packages/shared/models/push.py
+
+
+ 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.
+
+
+
+
+ Task 2: Push subscription client, service worker push handler, install prompt, offline queue
+
+ 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
+
+
+ 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 `` and `` 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.
+
+
+ cd /home/adelorenzo/repos/konstruct/packages/portal && npm run build
+
+
+ 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.
+
+
+
+
+
+
+- `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)
+
+
+
+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.
+
+
+
diff --git a/.planning/phases/08-mobile-pwa/08-04-PLAN.md b/.planning/phases/08-mobile-pwa/08-04-PLAN.md
new file mode 100644
index 0000000..02addbb
--- /dev/null
+++ b/.planning/phases/08-mobile-pwa/08-04-PLAN.md
@@ -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: []
+---
+
+
+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.
+
+
+
+@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
+@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
+
+
+
+@.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
+
+
+
+
+
+ Task 1: Verify all mobile and PWA features
+
+
+ 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.
+
+
+ cd /home/adelorenzo/repos/konstruct/packages/portal && npm run build
+
+
+ Human confirms all six MOB requirements pass on mobile viewports. Lighthouse PWA audit score >= 90. No hover-stuck interactions on touch. Phase 8 complete.
+
+
+
+
+
+
+All MOB requirements verified by human testing on mobile viewports.
+
+
+
+Human confirms all six MOB requirements pass on mobile viewports. Lighthouse PWA audit score >= 90.
+
+
+