+
Favorites
@@ -426,7 +426,7 @@ export const ModelMultiSelect: React.FC
= ({
{filteredRecents.length > 0 && (
<>
{filteredFavorites.length > 0 && }
-
+
Recent
@@ -446,7 +446,7 @@ export const ModelMultiSelect: React.FC
= ({
{filteredProviders.map((provider, index) => (
{index > 0 && }
-
+
= ({
{/* Favorites Section */}
{filteredFavorites.length > 0 && (
<>
-
+
Favorites
@@ -633,7 +633,7 @@ export const ModelSelector: React.FC = ({
{filteredRecents.length > 0 && (
<>
{filteredFavorites.length > 0 && }
-
+
Recent
@@ -653,7 +653,7 @@ export const ModelSelector: React.FC = ({
{filteredProviders.map((provider, index) => (
{index > 0 && }
-
+
{
- const settingsFile = getSettingsFile(dataDir)
- const result = await getOrCreateSettingsValue({
- settingsFile,
- readValue: (settings) => {
- if (settings.vapidKeys?.publicKey && settings.vapidKeys?.privateKey) {
- return { value: settings.vapidKeys }
- }
- return null
- },
- writeValue: (settings, value) => {
- settings.vapidKeys = value
- },
- generate: () => {
- const generated = generateVAPIDKeys()
- return {
- publicKey: generated.publicKey,
- privateKey: generated.privateKey
- }
- }
- })
-
- return result.value
-}
-```
-
-**Process:**
-- Keys generated once on startup using `web-push`
-- Keys persisted to settings file for later reuse
-- Only public key exposed to frontend
-
----
-
-### 1.2 Push Service
-
-**Location:** `server/src/push/pushService.ts`
-
-```typescript
-import * as webPush from 'web-push'
-
-export type PushPayload = {
- title: string
- body: string
- tag?: string
- data?: {
- type: string
- sessionId: string
- url: string
- }
-}
-
-export class PushService {
- constructor(
- private readonly vapidKeys: VapidKeys,
- private readonly subject: string,
- private readonly store: Store
- ) {
- webPush.setVapidDetails(this.subject, this.vapidKeys.publicKey, this.vapidKeys.privateKey)
- }
-
- async sendToNamespace(namespace: string, payload: PushPayload): Promise {
- const subscriptions = this.store.push.getPushSubscriptionsByNamespace(namespace)
- if (subscriptions.length === 0) {
- return
- }
-
- const body = JSON.stringify(payload)
- await Promise.all(subscriptions.map((subscription) => {
- return this.sendToSubscription(namespace, subscription, body)
- }))
- }
-
- private async sendToSubscription(
- namespace: string,
- subscription: StoredSubscription,
- body: string
- ): Promise {
- const pushSubscription: PushSubscription = {
- endpoint: subscription.endpoint,
- keys: {
- p256dh: subscription.p256dh,
- auth: subscription.auth
- }
- }
-
- try {
- await webPush.sendNotification(pushSubscription, body)
- } catch (error) {
- const statusCode = typeof (error as { statusCode?: unknown }).statusCode === 'number'
- ? (error as { statusCode: number }).statusCode
- : null
-
- if (statusCode === 410) {
- // Subscription expired - remove it
- this.store.push.removePushSubscription(namespace, subscription.endpoint)
- return
- }
-
- console.error('[PushService] Failed to send notification:', error)
- }
- }
-}
-```
-
-**Process:**
-1. Gets subscriptions from database by namespace
-2. Sends notification to all subscribers using web-push
-3. Removes subscriptions that return 410 Gone
-4. Handles other errors gracefully
-
----
-
-### 1.3 Push Notification Channel
-
-**Location:** `server/src/push/pushNotificationChannel.ts`
-
-```typescript
-import type { Session } from '../sync/syncEngine'
-import type { NotificationChannel } from '../notifications/notificationTypes'
-import { getAgentName, getSessionName } from '../notifications/sessionInfo'
-import type { SSEManager } from '../sse/sseManager'
-import type { VisibilityTracker } from '../visibility/visibilityTracker'
-import type { PushPayload, PushService } from './pushService'
-
-export class PushNotificationChannel implements NotificationChannel {
- constructor(
- private readonly pushService: PushService,
- private readonly sseManager: SSEManager,
- private readonly visibilityTracker: VisibilityTracker,
- _appUrl: string
- ) {}
-
- async sendPermissionRequest(session: Session): Promise {
- if (!session.active) {
- return
- }
-
- const name = getSessionName(session)
- const request = session.agentState?.requests
- ? Object.values(session.agentState.requests)[0]
- : null
- const toolName = request?.tool ? ` (${request.tool})` : ''
-
- const payload: PushPayload = {
- title: 'Permission Request',
- body: `${name}${toolName}`,
- tag: `permission-${session.id}`,
- data: {
- type: 'permission-request',
- sessionId: session.id,
- url: this.buildSessionPath(session.id)
- }
- }
-
- // Try SSE first (for visible sessions)
- const url = payload.data?.url ?? this.buildSessionPath(session.id)
- if (this.visibilityTracker.hasVisibleConnection(session.namespace)) {
- const delivered = await this.sseManager.sendToast(session.namespace, {
- type: 'toast',
- data: {
- title: payload.title,
- body: payload.body,
- sessionId: session.id,
- url
- }
- })
- if (delivered > 0) {
- return
- }
- }
-
- // Fallback to push notification
- await this.pushService.sendToNamespace(session.namespace, payload)
- }
-
- async sendReady(session: Session): Promise {
- if (!session.active) {
- return
- }
-
- const agentName = getAgentName(session)
- const name = getSessionName(session)
-
- const payload: PushPayload = {
- title: 'Ready for input',
- body: `${agentName} is waiting in ${name}`,
- tag: `ready-${session.id}`,
- data: {
- type: 'ready',
- sessionId: session.id,
- url: this.buildSessionPath(session.id)
- }
- }
-
- // Try SSE first
- const url = payload.data?.url ?? this.buildSessionPath(session.id)
- if (this.visibilityTracker.hasVisibleConnection(session.namespace)) {
- const delivered = await this.sseManager.sendToast(session.namespace, {
- type: 'toast',
- data: {
- title: payload.title,
- body: payload.body,
- sessionId: session.id,
- url
- }
- })
- if (delivered > 0) {
- return
- }
- }
-
- // Fallback to push notification
- await this.pushService.sendToNamespace(session.namespace, payload)
- }
-
- private buildSessionPath(sessionId: string): string {
- return `/sessions/${sessionId}`
- }
-}
-```
-
-**Strategy:**
-- **SSE Priority**: Checks if session is visible in current tab/window (SSE)
-- **Push Fallback**: If not visible, sends background notification via VAPID
-- **Tagging**: Uses session ID for grouping same-type notifications
-
----
-
-### 1.4 Notification Hub
-
-**Location:** `server/src/notifications/notificationHub.ts`
-
-```typescript
-import type { Session, SyncEvent } from '../sync/syncEngine'
-import type { NotificationChannel, NotificationHubOptions } from './notificationTypes'
-import { extractMessageEventType } from './eventParsing'
-
-export class NotificationHub {
- private readonly channels: NotificationChannel[]
- private readonly readyCooldownMs: number
- private readonly permissionDebounceMs: number
- private readonly lastKnownRequests: Map> = new Map()
- private readonly notificationDebounce: Map = new Map()
- private readonly lastReadyNotificationAt: Map = new Map()
-
- constructor(
- private readonly syncEngine: SyncEngine,
- channels: NotificationChannel[],
- options?: NotificationHubOptions
- ) {
- this.channels = channels
- this.readyCooldownMs = options?.readyCooldownMs ?? 5000
- this.permissionDebounceMs = options?.permissionDebounceMs ?? 500
- this.unsubscribeSyncEvents = this.syncEngine.subscribe((event) => {
- this.handleSyncEvent(event)
- })
- }
-
- private handleSyncEvent(event: SyncEvent): void {
- // Permission notifications
- if ((event.type === 'session-updated' || event.type === 'session-added') && event.sessionId) {
- const session = this.syncEngine.getSession(event.sessionId)
- if (!session || !session.active) {
- this.clearSessionState(event.sessionId)
- return
- }
- this.checkForPermissionNotification(session)
- return
- }
-
- if (event.type === 'session-removed' && event.sessionId) {
- this.clearSessionState(event.sessionId)
- return
- }
-
- // Ready notifications
- if (event.type === 'message-received' && event.sessionId) {
- const eventType = extractMessageEventType(event)
- if (eventType === 'ready') {
- this.sendReadyNotification(event.sessionId).catch((error) => {
- console.error('[NotificationHub] Failed to send ready notification:', error)
- })
- }
- }
- }
-
- private checkForPermissionNotification(session: Session): void {
- const requests = session.agentState?.requests
-
- if (requests == null) {
- return
- }
-
- const newRequestIds = new Set(Object.keys(requests))
- const oldRequestIds = this.lastKnownRequests.get(session.id) || new Set()
-
- let hasNewRequests = false
- for (const requestId of newRequestIds) {
- if (!oldRequestIds.has(requestId)) {
- hasNewRequests = true
- break
- }
- }
-
- this.lastKnownRequests.set(session.id, newRequestIds)
-
- if (!hasNewRequests) {
- return
- }
-
- // Debounce permission notifications
- const existingTimer = this.notificationDebounce.get(session.id)
- if (existingTimer) {
- clearTimeout(existingTimer)
- }
-
- const timer = setTimeout(() => {
- this.notificationDebounce.delete(session.id)
- this.sendPermissionNotification(session.id).catch((error) => {
- console.error('[NotificationHub] Failed to send permission notification:', error)
- })
- }, this.permissionDebounceMs)
-
- this.notificationDebounce.set(session.id, timer)
- }
-
- private clearSessionState(sessionId: string): void {
- const existingTimer = this.notificationDebounce.get(sessionId)
- if (existingTimer) {
- clearTimeout(existingTimer)
- this.notificationDebounce.delete(sessionId)
- }
- this.lastKnownRequests.delete(sessionId)
- this.lastReadyNotificationAt.delete(sessionId)
- }
-
- private getNotifiableSession(sessionId: string): Session | null {
- const session = this.syncEngine.getSession(sessionId)
- if (!session || !session.active) {
- return null
- }
- return session
- }
-}
-```
-
-**Features:**
-- **Event-Based**: Listens to sync events from SyncEngine
-- **Debouncing**:
- - Permission requests: 500ms debounce
- - Ready notifications: 5s cooldown
-- **State Tracking**: Remembers last-known requests to detect new ones
-- **Multi-Channel**: Forwards to all registered channels
-
----
-
-### 1.5 Subscription Storage
-
-**Location:** `server/src/store/pushSubscriptions.ts`
-
-```typescript
-import type { Database } from 'bun:sqlite'
-import type { StoredPushSubscription } from './types'
-
-type DbPushSubscriptionRow = {
- id: number
- namespace: string
- endpoint: string
- p256dh: string
- auth: string
- created_at: number
-}
-
-export function addPushSubscription(
- db: Database,
- namespace: string,
- subscription: { endpoint: string; p256dh: string; auth: string }
-): void {
- const now = Date.now()
- db.prepare(`
- INSERT INTO push_subscriptions (
- namespace, endpoint, p256dh, auth, created_at
- ) VALUES (
- @namespace, @endpoint, @p256dh, @auth, @created_at
- )
- ON CONFLICT(namespace, endpoint)
- DO UPDATE SET
- p256dh = excluded.p256dh,
- auth = excluded.auth,
- created_at = excluded.created_at
- `).run({
- namespace,
- endpoint: subscription.endpoint,
- p256dh: subscription.p256dh,
- auth: subscription.auth,
- created_at: now
- })
-}
-
-export function removePushSubscription(db: Database, namespace: string, endpoint: string): void {
- db.prepare(
- 'DELETE FROM push_subscriptions WHERE namespace = ? AND endpoint = ?'
- ).run(namespace, endpoint)
-}
-
-export function getPushSubscriptionsByNamespace(
- db: Database,
- namespace: string
-): StoredPushSubscription[] {
- const rows = db.prepare(
- 'SELECT * FROM push_subscriptions WHERE namespace = ? ORDER BY created_at DESC'
- ).all(namespace) as DbPushSubscriptionRow[]
- return rows.map(toStoredPushSubscription)
-}
-```
-
-**Storage Strategy:**
-- SQLite database with namespace-based isolation
-- Upsert on duplicate (updates keys if same endpoint)
-- Timestamps for ordering
-
----
-
-### 1.6 Push API Routes
-
-**Location:** `server/src/web/routes/push.ts`
-
-```typescript
-import { Hono } from 'hono'
-import { z } from 'zod'
-import type { Store } from '../../store'
-import type { WebAppEnv } from '../middleware/auth'
-
-const subscriptionSchema = z.object({
- endpoint: z.string().min(1),
- keys: z.object({
- p256dh: z.string().min(1),
- auth: z.string().min(1)
- })
-})
-
-const unsubscribeSchema = z.object({
- endpoint: z.string().min(1)
-})
-
-export function createPushRoutes(store: Store, vapidPublicKey: string): Hono {
- const app = new Hono()
-
- app.get('/push/vapid-public-key', (c) => {
- return c.json({ publicKey: vapidPublicKey })
- })
-
- app.post('/push/subscribe', async (c) => {
- const json = await c.req.json().catch(() => null)
- const parsed = subscriptionSchema.safeParse(json)
- if (!parsed.success) {
- return c.json({ error: 'Invalid body' }, 400)
- }
-
- const namespace = c.get('namespace')
- const { endpoint, keys } = parsed.data
- store.push.addPushSubscription(namespace, {
- endpoint,
- p256dh: keys.p256dh,
- auth: keys.auth
- })
-
- return c.json({ ok: true })
- })
-
- app.delete('/push/subscribe', async (c) => {
- const json = await c.req.json().catch(() => null)
- const parsed = unsubscribeSchema.safeParse(json)
- if (!parsed.success) {
- return c.json({ error: 'Invalid body' }, 400)
- }
-
- const namespace = c.get('namespace')
- store.push.removePushSubscription(namespace, parsed.data.endpoint)
- return c.json({ ok: true })
- })
-
- return app
-}
-```
-
-**API Endpoints:**
-- `GET /push/vapid-public-key` - Exposes VAPID public key
-- `POST /push/subscribe` - Register push subscription
-- `DELETE /push/subscribe` - Unsubscribe
-
----
-
-### 1.7 Service Worker
-
-**Location:** `web/src/sw.ts`
-
-```typescript
-///
-import { precacheAndRoute } from 'workbox-precaching'
-import { registerRoute } from 'workbox-routing'
-import { CacheFirst, NetworkFirst } from 'workbox-strategies'
-import { ExpirationPlugin } from 'workbox-expiration'
-
-declare const self: ServiceWorkerGlobalScope & {
- __WB_MANIFEST: Array
-}
-
-type PushPayload = {
- title: string
- body?: string
- icon?: string
- badge?: string
- tag?: string
- data?: {
- type?: string
- sessionId?: string
- url?: string
- }
-}
-
-precacheAndRoute(self.__WB_MANIFEST)
-
-// Cache API responses
-registerRoute(
- ({ url }) => url.pathname === '/api/sessions',
- new NetworkFirst({
- cacheName: 'api-sessions',
- networkTimeoutSeconds: 10,
- plugins: [
- new ExpirationPlugin({
- maxEntries: 10,
- maxAgeSeconds: 60 * 5
- })
- ]
- })
-)
-
-// Handle push notifications
-self.addEventListener('push', (event) => {
- const payload = event.data?.json() as PushPayload | undefined
- if (!payload) {
- return
- }
-
- const title = payload.title || 'HAPI'
- const body = payload.body ?? ''
- const icon = payload.icon ?? '/pwa-192x192.png'
- const badge = payload.badge ?? '/pwa-64x64.png'
- const data = payload.data
- const tag = payload.tag
-
- event.waitUntil(
- self.registration.showNotification(title, {
- body,
- icon,
- badge,
- data,
- tag
- })
- )
-})
-
-// Handle notification clicks
-self.addEventListener('notificationclick', (event) => {
- event.notification.close()
- const data = event.notification.data as { url?: string } | undefined
- const url = data?.url ?? '/'
- event.waitUntil(self.clients.openWindow(url))
-})
-```
-
-**Features:**
-- Uses Workbox for caching strategies
-- Push event handler: Shows notification with custom payload
-- Click handler: Opens app at specific URL (deep linking)
-
----
-
-## 2. Frontend Implementation
-
-### 2.1 React Hook for Push Management
-
-**Location:** `web/src/hooks/usePushNotifications.ts`
-
-```typescript
-import { useCallback, useEffect, useState } from 'react'
-import type { ApiClient } from '@/api/client'
-
-function isPushSupported(): boolean {
- return typeof window !== 'undefined'
- && 'serviceWorker' in navigator
- && 'PushManager' in window
- && 'Notification' in window
-}
-
-function base64UrlToUint8Array(base64Url: string): Uint8Array {
- const padding = '='.repeat((4 - (base64Url.length % 4)) % 4)
- const base64 = (base64Url + padding)
- .replace(/-/g, '+')
- .replace(/_/g, '/')
- const raw = atob(base64)
- const output = new Uint8Array(raw.length)
- for (let i = 0; i < raw.length; i += 1) {
- output[i] = raw.charCodeAt(i)
- }
- return output
-}
-
-export function usePushNotifications(api: ApiClient | null) {
- const [isSupported, setIsSupported] = useState(false)
- const [permission, setPermission] = useState('default')
- const [isSubscribed, setIsSubscribed] = useState(false)
-
- const refreshSubscription = useCallback(async () => {
- if (!isPushSupported()) {
- setIsSupported(false)
- setIsSubscribed(false)
- return
- }
-
- setIsSupported(true)
- setPermission(Notification.permission)
-
- if (Notification.permission !== 'granted') {
- setIsSubscribed(false)
- return
- }
-
- const registration = await navigator.serviceWorker.ready
- const subscription = await registration.pushManager.getSubscription()
- setIsSubscribed(Boolean(subscription))
- }, [])
-
- useEffect(() => {
- void refreshSubscription()
- }, [refreshSubscription])
-
- const requestPermission = useCallback(async (): Promise => {
- if (!isPushSupported()) {
- return false
- }
-
- const result = await Notification.requestPermission()
- setPermission(result)
- if (result !== 'granted') {
- setIsSubscribed(false)
- }
- return result === 'granted'
- }, [])
-
- const subscribe = useCallback(async (): Promise => {
- if (!api || !isPushSupported()) {
- return false
- }
-
- if (Notification.permission !== 'granted') {
- setPermission(Notification.permission)
- return false
- }
-
- try {
- const registration = await navigator.serviceWorker.ready
- const existing = await registration.pushManager.getSubscription()
- const { publicKey } = await api.getPushVapidPublicKey()
- const applicationServerKey = base64UrlToUint8Array(publicKey).buffer as ArrayBuffer
- const subscription = existing ?? await registration.pushManager.subscribe({
- userVisibleOnly: true,
- applicationServerKey
- })
-
- const json = subscription.toJSON()
- const keys = json.keys
- if (!json.endpoint || !keys?.p256dh || !keys.auth) {
- return false
- }
-
- await api.subscribePushNotifications({
- endpoint: json.endpoint,
- keys: {
- p256dh: keys.p256dh,
- auth: keys.auth
- }
- })
- setIsSubscribed(true)
- return true
- } catch (error) {
- console.error('[PushNotifications] Failed to subscribe:', error)
- return false
- }
- }, [api])
-
- const unsubscribe = useCallback(async (): Promise => {
- if (!api || !isPushSupported()) {
- return false
- }
-
- try {
- const registration = await navigator.serviceWorker.ready
- const subscription = await registration.pushManager.getSubscription()
- if (!subscription) {
- setIsSubscribed(false)
- return true
- }
-
- const endpoint = subscription.endpoint
- const success = await subscription.unsubscribe()
- await api.unsubscribePushNotifications({ endpoint })
- setIsSubscribed(false)
- return success
- } catch (error) {
- console.error('[PushNotifications] Failed to unsubscribe:', error)
- return false
- }
- }, [api])
-
- return {
- isSupported,
- permission,
- isSubscribed,
- requestPermission,
- subscribe,
- unsubscribe
- }
-}
-```
-
-**Process:**
-1. Check browser support
-2. Request permission on user action
-3. Subscribe using PushManager with VAPID key
-4. Send subscription details to server
-5. Unsubscribe by removing subscription
-
----
-
-### 2.2 PWA Configuration
-
-**Location:** `web/vite.config.ts`
-
-```typescript
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
-import { VitePWA } from 'vite-plugin-pwa'
-import { resolve } from 'node:path'
-
-export default defineConfig({
- plugins: [
- react(),
- VitePWA({
- registerType: 'autoUpdate',
- includeAssets: ['favicon.ico', 'apple-touch-icon-180x180.png', 'mask-icon.svg'],
- strategies: 'injectManifest',
- srcDir: 'src',
- filename: 'sw.ts',
- manifest: {
- name: 'HAPI',
- short_name: 'HAPI',
- description: 'AI-powered development assistant',
- theme_color: '#ffffff',
- background_color: '#ffffff',
- display: 'standalone',
- orientation: 'portrait',
- scope: base,
- start_url: base,
- icons: [
- {
- src: 'pwa-64x64.png',
- sizes: '64x64',
- type: 'image/png'
- },
- {
- src: 'pwa-192x192.png',
- sizes: '192x192',
- type: 'image/png'
- },
- {
- src: 'pwa-512x512.png',
- sizes: '512x512',
- type: 'image/png'
- },
- {
- src: 'maskable-icon-512x512.png',
- sizes: '512x512',
- type: 'image/png',
- purpose: 'maskable'
- }
- ]
- },
- injectManifest: {
- globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}']
- },
- devOptions: {
- enabled: true,
- type: 'module'
- }
- })
- ],
- // ...
-})
-```
-
-**Configuration:**
-- **Auto-update**: Service worker auto-updates every load
-- **Manifest**: Defines PWA metadata and icons
-- **Icons**: Multiple sizes including maskable
-
----
-
-### 2.3 Service Worker Registration
-
-**Location:** `web/src/main.tsx`
-
-```typescript
-import { registerSW } from 'virtual:pwa-register'
-
-async function bootstrap() {
- // ... other initialization
-
- const updateSW = registerSW({
- onNeedRefresh() {
- if (confirm('New version available! Reload to update?')) {
- updateSW(true)
- }
- },
- onOfflineReady() {
- console.log('App ready for offline use')
- },
- onRegistered(registration) {
- if (registration) {
- // Auto-update every hour
- setInterval(() => {
- registration.update()
- }, 60 * 60 * 1000)
- }
- },
- onRegisterError(error) {
- console.error('SW registration error:', error)
- }
- })
-
- // ...
-}
-
-bootstrap()
-```
-
----
-
-### 2.4 App Integration
-
-**Location:** `web/src/App.tsx`
-
-```typescript
-const { isSupported: isPushSupported, permission: pushPermission, requestPermission, subscribe } = usePushNotifications(api)
-
-useEffect(() => {
- if (!api || !token) {
- pushPromptedRef.current = false
- return
- }
- if (isTelegramApp() || !isPushSupported) {
- return
- }
- if (pushPromptedRef.current) {
- return
- }
- pushPromptedRef.current = true
-
- const run = async () => {
- if (pushPermission === 'granted') {
- await subscribe()
- return
- }
- if (pushPermission === 'default') {
- const granted = await requestPermission()
- if (granted) {
- await subscribe()
- }
- }
- }
-
- void run()
-}, [api, isPushSupported, pushPermission, requestPermission, subscribe, token])
-```
-
-**Strategy:**
-- Prompt user for permission once per session
-- Auto-subscribe if permission granted
-- Don't prompt in Telegram environment (uses built-in notifications)
-
----
-
-## 3. Full Data Flow
-
-### 3.1 Subscription Flow
-
-```
-User action (App.tsx)
- ↓
-requestPermission() (usePushNotifications.ts)
- ↓
-Browser prompt
- ↓
-User approves → Notification.permission = 'granted'
- ↓
-subscribe() (usePushNotifications.ts)
- ↓
-navigator.serviceWorker.ready
- ↓
-registration.pushManager.subscribe()
- ↓
-Get subscription keys (endpoint, p256dh, auth)
- ↓
-POST /api/push/subscribe (api.client.ts)
- ↓
-Server stores in database (pushSubscriptions.ts)
-```
-
----
-
-### 3.2 Notification Delivery Flow
-
-```
-Event occurs (session state change)
- ↓
-NotificationHub.handleSyncEvent()
- ↓
-checkForPermissionNotification() / sendReadyNotification()
- ↓
-NotificationHub.notifyPermission() / notifyReady()
- ↓
-PushNotificationChannel.sendPermissionRequest() / sendReady()
- ↓
-Check visibility (VisibilityTracker)
- ↓
- ├─ Visible → SSE toast (immediate)
- ↓
- └─ Not visible → Push notification
- ↓
- PushService.sendToNamespace()
- ↓
- Get subscriptions from DB
- ↓
- web-push.sendNotification()
- ↓
- Device receives push
- ↓
- Service Worker push event
- ↓
- showNotification() (sw.ts)
- ↓
- User sees notification
- ↓
- Click handler opens app
-```
-
----
-
-## 4. Key Implementation Details
-
-### 4.1 VAPID Authentication
-
-```typescript
-// Server
-webPush.setVapidDetails(
- subject, // mailto:admin@hapi.run
- publicKey, // From getOrCreateVapidKeys()
- privateKey // From getOrCreateVapidKeys()
-)
-
-// Frontend
-const { publicKey } = await api.getPushVapidPublicKey()
-const applicationServerKey = base64UrlToUint8Array(publicKey).buffer
-```
-
-**Why VAPID:**
-- Validates sender (prevents spam)
-- Required for service worker push to work
-- Uses asymmetric crypto (public/private key pair)
-
----
-
-### 4.2 Tagging Strategy
-
-```typescript
-{
- title: 'Ready for input',
- tag: `ready-${session.id}`, // Group notifications
- data: {
- type: 'ready',
- sessionId: session.id,
- url: '/sessions/123'
- }
-}
-```
-
-**Benefits:**
-- Same-type notifications combine into single item
-- User can dismiss all similar notifications at once
-- Deep linking via URL
-
----
-
-### 4.3 Error Handling
-
-```typescript
-// Service worker
-self.addEventListener('push', (event) => {
- event.waitUntil(
- self.registration.showNotification(title, options)
- )
-})
-
-// Push service
-try {
- await webPush.sendNotification(pushSubscription, body)
-} catch (error) {
- if (statusCode === 410) {
- removeSubscription() // Subscription expired
- }
-}
-```
-
----
-
-### 4.4 Database Schema
-
-```sql
-CREATE TABLE push_subscriptions (
- id INTEGER PRIMARY KEY,
- namespace TEXT NOT NULL,
- endpoint TEXT NOT NULL UNIQUE,
- p256dh TEXT NOT NULL,
- auth TEXT NOT NULL,
- created_at INTEGER NOT NULL
-);
-```
-
----
-
-## 5. Testing Checklist
-
-### Server-side
-- [ ] VAPID keys generated correctly
-- [ ] Subscription stored in DB
-- [ ] Notification sent to valid subscription
-- [ ] 410 Gone handled (remove expired subscriptions)
-- [ ] Routes return proper error codes
-
-### Frontend
-- [ ] Push supported check works
-- [ ] Permission request triggered correctly
-- [ ] Subscription created with correct keys
-- [ ] Keys sent to server
-- [ ] Unsubscribe works
-- [ ] Deep linking opens correct URL
-
-### Integration
-- [ ] Permission notification sent when new request appears
-- [ ] Ready notification sent after AI responds
-- [ ] Debouncing prevents spam
-- [ ] Fallback to SSE when visible
-- [ ] Push used when not visible
-
----
-
-## 6. Common Pitfalls
-
-1. **VAPID Key Mismatch** - Frontend public key must match server private key
-2. **Wrong Content-Type** - Push payload must be string (not JSON object)
-3. **Missing Icon** - Notification needs icon to display correctly
-4. **Service Worker Not Registered** - Hook checks for support but SW may fail to load
-5. **PushManager Not Available** - Some browsers block push in non-HTTPS contexts
-6. **Database Connection** - Store must be initialized before creating subscription routes
-
----
-
-## 7. Recommendations for Your Project
-
-1. **Add clear permission prompt** - Don't auto-prompt on load, wait for user action
-2. **Include icons** - Notification needs 192x192+ icon and optional badge
-3. **Handle location** - Use `tag` to group notifications
-4. **Test on real device** - Most browsers require HTTPS and device support for push
-5. **Fallback to in-app** - Always try SSE/Socket.IO first, use push as fallback
-6. **Clear error messages** - Show why push failed (permission, support, etc.)
-7. **Debounce events** - Prevent notification spam with 500ms-5s delays
-
----
-
-## Summary
-
-This implementation uses a robust, production-ready approach combining:
-
-- **VAPID Authentication** for secure push delivery
-- **Workbox Service Worker** for caching and notification handling
-- **Hybrid Delivery** (SSE first, push fallback) for optimal UX
-- **Debouncing & Cooldown** to prevent spam
-- **Database Persistence** for subscription management
-- **Deep Linking** for seamless user experience
-
-The architecture is modular and extensible, making it easy to add more notification channels (Telegram, Slack, etc.) without changing core logic.