diff --git a/ui/public/sw.js b/ui/public/sw.js index f90d1215..baa67362 100644 --- a/ui/public/sw.js +++ b/ui/public/sw.js @@ -1,42 +1,88 @@ -const CACHE_NAME = "paperclip-v2"; +const CACHE_NAME = "nexus-v1"; +const PRECACHE_URLS = ["/", "/index.html"]; +const STATIC_EXTENSION_RE = /\.(js|css|woff2?|png|svg|ico|webmanifest)$/; -self.addEventListener("install", () => { +// ── Install ────────────────────────────────────────────────────────────────── +self.addEventListener("install", (event) => { self.skipWaiting(); + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)) + ); }); +// ── Activate ───────────────────────────────────────────────────────────────── +// Delete all caches except nexus-v1 (removes any stale caches from prior versions). self.addEventListener("activate", (event) => { event.waitUntil( - caches.keys().then((keys) => - Promise.all(keys.map((key) => caches.delete(key))) - ) + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)) + ) + ) + .then(() => self.clients.claim()) ); - self.clients.claim(); }); +// ── Fetch ───────────────────────────────────────────────────────────────────── self.addEventListener("fetch", (event) => { const { request } = event; const url = new URL(request.url); - // Skip non-GET requests and API calls - if (request.method !== "GET" || url.pathname.startsWith("/api")) { + // API calls — network only, no interception + if (url.pathname.startsWith("/api")) { return; } - // Network-first for everything — cache is only an offline fallback - event.respondWith( - fetch(request) - .then((response) => { - if (response.ok && url.origin === self.location.origin) { - const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); - } - return response; - }) - .catch(() => { - if (request.mode === "navigate") { - return caches.match("/") || new Response("Offline", { status: 503 }); - } - return caches.match(request); + // Navigation requests — cache-first: serve shell from cache, fall back to network + if (request.mode === "navigate") { + event.respondWith( + caches.match("/").then((cached) => cached || fetch(request)) + ); + return; + } + + // Static assets — cache-first: serve from cache, on miss fetch and cache + if (STATIC_EXTENSION_RE.test(url.pathname)) { + event.respondWith( + caches.match(request).then((cached) => { + if (cached) return cached; + return fetch(request).then((response) => { + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + } + return response; + }); }) + ); + return; + } + + // All other requests — pass through, no interception +}); + +// ── Push Notifications ──────────────────────────────────────────────────────── +self.addEventListener("push", (event) => { + const data = event.data ? event.data.json() : {}; + const { title = "Nexus", body = "", icon, data: notifData } = data; + + event.waitUntil( + self.registration.showNotification(title, { + body, + icon: icon || "/android-chrome-192x192.png", + badge: "/favicon-32x32.png", + data: notifData, + }) ); }); + +// ── Notification Click ──────────────────────────────────────────────────────── +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const targetUrl = event.notification.data?.url || "/"; + event.waitUntil(clients.openWindow(targetUrl)); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b022235b..8d69a81e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,56 +1,57 @@ import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router"; import { VOCAB } from "@paperclipai/branding"; import { useQuery } from "@tanstack/react-query"; +import { lazy, Suspense } from "react"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { Layout } from "./components/Layout"; import { OnboardingWizard } from "./components/OnboardingWizard"; import { authApi } from "./api/auth"; import { healthApi } from "./api/health"; -import { Dashboard } from "./pages/Dashboard"; -import { Companies } from "./pages/Companies"; -import { Agents } from "./pages/Agents"; -import { AgentDetail } from "./pages/AgentDetail"; -import { Projects } from "./pages/Projects"; -import { ProjectDetail } from "./pages/ProjectDetail"; -import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail"; -import { Issues } from "./pages/Issues"; -import { IssueDetail } from "./pages/IssueDetail"; -import { Routines } from "./pages/Routines"; -import { RoutineDetail } from "./pages/RoutineDetail"; -import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail"; -import { Goals } from "./pages/Goals"; -import { GoalDetail } from "./pages/GoalDetail"; -import { Approvals } from "./pages/Approvals"; -import { ApprovalDetail } from "./pages/ApprovalDetail"; -import { Costs } from "./pages/Costs"; -import { Activity } from "./pages/Activity"; -import { Inbox } from "./pages/Inbox"; -import { CompanySettings } from "./pages/CompanySettings"; -import { SkillBrowser } from "./pages/SkillBrowser"; -import { SkillDetail } from "./pages/SkillDetail"; -import { CompanyExport } from "./pages/CompanyExport"; -import { CompanyImport } from "./pages/CompanyImport"; -import { DesignGuide } from "./pages/DesignGuide"; -import { InstanceGeneralSettings } from "./pages/InstanceGeneralSettings"; -import { InstanceSettings } from "./pages/InstanceSettings"; -import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings"; -import { PluginManager } from "./pages/PluginManager"; -import { PluginSettings } from "./pages/PluginSettings"; -import { PluginPage } from "./pages/PluginPage"; -import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; -import { OrgChart } from "./pages/OrgChart"; -import { NewAgent } from "./pages/NewAgent"; -import { AuthPage } from "./pages/Auth"; -import { BoardClaimPage } from "./pages/BoardClaim"; -import { CliAuthPage } from "./pages/CliAuth"; -import { InviteLandingPage } from "./pages/InviteLanding"; -import { NotFoundPage } from "./pages/NotFound"; import { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; import { loadLastInboxTab } from "./lib/inbox"; import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route"; +const Dashboard = lazy(() => import("./pages/Dashboard").then(m => ({ default: m.Dashboard }))); +const Companies = lazy(() => import("./pages/Companies").then(m => ({ default: m.Companies }))); +const Agents = lazy(() => import("./pages/Agents").then(m => ({ default: m.Agents }))); +const AgentDetail = lazy(() => import("./pages/AgentDetail").then(m => ({ default: m.AgentDetail }))); +const Projects = lazy(() => import("./pages/Projects").then(m => ({ default: m.Projects }))); +const ProjectDetail = lazy(() => import("./pages/ProjectDetail").then(m => ({ default: m.ProjectDetail }))); +const Issues = lazy(() => import("./pages/Issues").then(m => ({ default: m.Issues }))); +const IssueDetail = lazy(() => import("./pages/IssueDetail").then(m => ({ default: m.IssueDetail }))); +const Routines = lazy(() => import("./pages/Routines").then(m => ({ default: m.Routines }))); +const RoutineDetail = lazy(() => import("./pages/RoutineDetail").then(m => ({ default: m.RoutineDetail }))); +const ExecutionWorkspaceDetail = lazy(() => import("./pages/ExecutionWorkspaceDetail").then(m => ({ default: m.ExecutionWorkspaceDetail }))); +const Goals = lazy(() => import("./pages/Goals").then(m => ({ default: m.Goals }))); +const GoalDetail = lazy(() => import("./pages/GoalDetail").then(m => ({ default: m.GoalDetail }))); +const Approvals = lazy(() => import("./pages/Approvals").then(m => ({ default: m.Approvals }))); +const ApprovalDetail = lazy(() => import("./pages/ApprovalDetail").then(m => ({ default: m.ApprovalDetail }))); +const Costs = lazy(() => import("./pages/Costs").then(m => ({ default: m.Costs }))); +const Activity = lazy(() => import("./pages/Activity").then(m => ({ default: m.Activity }))); +const Inbox = lazy(() => import("./pages/Inbox").then(m => ({ default: m.Inbox }))); +const CompanySettings = lazy(() => import("./pages/CompanySettings").then(m => ({ default: m.CompanySettings }))); +const CompanySkills = lazy(() => import("./pages/CompanySkills").then(m => ({ default: m.CompanySkills }))); +const CompanyExport = lazy(() => import("./pages/CompanyExport").then(m => ({ default: m.CompanyExport }))); +const CompanyImport = lazy(() => import("./pages/CompanyImport").then(m => ({ default: m.CompanyImport }))); +const DesignGuide = lazy(() => import("./pages/DesignGuide").then(m => ({ default: m.DesignGuide }))); +const InstanceGeneralSettings = lazy(() => import("./pages/InstanceGeneralSettings").then(m => ({ default: m.InstanceGeneralSettings }))); +const InstanceSettings = lazy(() => import("./pages/InstanceSettings").then(m => ({ default: m.InstanceSettings }))); +const InstanceExperimentalSettings = lazy(() => import("./pages/InstanceExperimentalSettings").then(m => ({ default: m.InstanceExperimentalSettings }))); +const PluginManager = lazy(() => import("./pages/PluginManager").then(m => ({ default: m.PluginManager }))); +const PluginSettings = lazy(() => import("./pages/PluginSettings").then(m => ({ default: m.PluginSettings }))); +const PluginPage = lazy(() => import("./pages/PluginPage").then(m => ({ default: m.PluginPage }))); +const RunTranscriptUxLab = lazy(() => import("./pages/RunTranscriptUxLab").then(m => ({ default: m.RunTranscriptUxLab }))); +const OrgChart = lazy(() => import("./pages/OrgChart").then(m => ({ default: m.OrgChart }))); +const NewAgent = lazy(() => import("./pages/NewAgent").then(m => ({ default: m.NewAgent }))); +const AuthPage = lazy(() => import("./pages/Auth").then(m => ({ default: m.AuthPage }))); +const BoardClaimPage = lazy(() => import("./pages/BoardClaim").then(m => ({ default: m.BoardClaimPage }))); +const CliAuthPage = lazy(() => import("./pages/CliAuth").then(m => ({ default: m.CliAuthPage }))); +const InviteLandingPage = lazy(() => import("./pages/InviteLanding").then(m => ({ default: m.InviteLandingPage }))); +const NotFoundPage = lazy(() => import("./pages/NotFound").then(m => ({ default: m.NotFoundPage }))); + function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { return (
@@ -128,8 +129,7 @@ function boardRoutes() { } /> } /> } /> - } /> - } /> + } /> } /> } /> } /> @@ -148,8 +148,6 @@ function boardRoutes() { } /> } /> } /> - } /> - } /> } /> } /> } /> @@ -204,13 +202,13 @@ function OnboardingRoutePage() { const title = matchedCompany ? `Add another agent to ${matchedCompany.name}` : companies.length > 0 - ? `Create another ${VOCAB.company.toLowerCase()}` - : `Create your first ${VOCAB.company.toLowerCase()}`; + ? "Create another company" + : "Create your first company"; const description = matchedCompany - ? `Run onboarding again to add an agent and a starter task for this ${VOCAB.company.toLowerCase()}.` + ? "Run onboarding again to add an agent and a starter task for this company." : companies.length > 0 - ? `Run onboarding again to create another ${VOCAB.company.toLowerCase()} and seed its first agent.` - : `Get started by creating a ${VOCAB.company.toLowerCase()} and your first agent.`; + ? "Run onboarding again to create another company and seed its first agent." + : "Get started by creating a company and your first agent."; return (
@@ -292,12 +290,12 @@ function NoCompaniesStartPage() { return (
-

{`Create your first ${VOCAB.company.toLowerCase()}`}

+

Create your first company

- {`Get started by creating a ${VOCAB.company.toLowerCase()}.`} + Get started by creating a company.

- +
@@ -307,53 +305,52 @@ function NoCompaniesStartPage() { export function App() { return ( <> - - } /> - } /> - } /> - } /> +
}> + + } /> + } /> + } /> + } /> - }> - } /> - } /> - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + }> + {boardRoutes()} + + } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - }> - {boardRoutes()} - - } /> - - + + ); diff --git a/ui/src/components/ChatPanel.tsx b/ui/src/components/ChatPanel.tsx index 427b42f6..bec3bb78 100644 --- a/ui/src/components/ChatPanel.tsx +++ b/ui/src/components/ChatPanel.tsx @@ -13,6 +13,8 @@ import { ChatSearchDialog } from "./ChatSearchDialog"; import { ChatBranchSelector } from "./ChatBranchSelector"; import { ChatBookmarkList } from "./ChatBookmarkList"; import { MobileChatView } from "./MobileChatView"; +import { InstallPromptBanner } from "./InstallPromptBanner"; +import { OfflineBanner } from "./OfflineBanner"; import { Button } from "@/components/ui/button"; import { chatApi } from "../api/chat"; import { agentsApi } from "../api/agents"; @@ -22,6 +24,8 @@ import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault"; import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks"; import { useChatFileUpload } from "../hooks/useChatFileUpload"; import { useMediaQuery } from "../hooks/useMediaQuery"; +import { useOfflineQueue } from "../hooks/useOfflineQueue"; +import { useOnlineStatus } from "../hooks/useOnlineStatus"; import { resolveAgentFromContent } from "../lib/slash-commands"; import type { AgentRole } from "@paperclipai/shared"; @@ -39,6 +43,8 @@ export function ChatPanel() { const { messages } = useChatMessages(activeConversationId); const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId); const { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds } = useChatFileUpload(activeConversationId); + const { enqueue, queuedCount } = useOfflineQueue(); + const isOnline = useOnlineStatus(); const brainstormerDefaultId = useBrainstormerDefault(); @@ -134,6 +140,15 @@ export function ChatPanel() { const handleSend = async (content: string) => { if (!selectedCompanyId) return; + // If offline, enqueue the message and show a toast + if (!isOnline) { + if (activeConversationId) { + await enqueue(activeConversationId, content); + pushToast({ title: "Message queued — will send when you're back online", tone: "info" }); + } + return; + } + // Resolve agent from slash command or @mention const resolvedAgentId = resolveAgentFromContent(content, agents, activeAgentId); @@ -260,6 +275,9 @@ export function ChatPanel() { className="hidden md:flex overflow-hidden transition-[width] duration-100 ease-out flex-shrink-0 border-l border-border flex-col bg-background" style={{ width: chatOpen ? 380 : 0 }} > + {/* Offline status banner */} + + {/* Header with agent selector */}
Chat @@ -409,6 +427,9 @@ export function ChatPanel() { companyId={selectedCompanyId} onNavigate={handleSearchNavigate} /> + + {/* PWA install prompt banner (self-contained show/hide logic) */} + ); } diff --git a/ui/src/components/InstallPromptBanner.tsx b/ui/src/components/InstallPromptBanner.tsx new file mode 100644 index 00000000..8a6a492d --- /dev/null +++ b/ui/src/components/InstallPromptBanner.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useInstallPrompt } from "../hooks/useInstallPrompt"; + +const DISMISS_KEY = "nexus.installPromptDismissed"; +const DISMISS_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +function isDismissed(): boolean { + try { + const stored = localStorage.getItem(DISMISS_KEY); + if (!stored) return false; + const dismissedAt = parseInt(stored, 10); + return Date.now() - dismissedAt < DISMISS_COOLDOWN_MS; + } catch { + return false; + } +} + +/** + * PWA install prompt banner. + * + * Shows when: + * - The browser has fired beforeinstallprompt (canInstall) OR the device is iOS + * - The app is not already installed (running in standalone mode) + * - The user has not dismissed within the last 7 days + * + * Positioned above MobileBottomNav on mobile and top-right on desktop. + */ +export function InstallPromptBanner() { + const { canInstall, promptInstall, isIOS } = useInstallPrompt(); + const [dismissed, setDismissed] = useState(() => isDismissed()); + + if (dismissed) return null; + if (!canInstall && !isIOS) return null; + + const handleInstall = async () => { + if (isIOS) return; // iOS: user follows Share menu instructions + await promptInstall(); + }; + + const handleDismiss = () => { + try { + localStorage.setItem(DISMISS_KEY, String(Date.now())); + } catch { + // localStorage unavailable — dismiss for session only + } + setDismissed(true); + }; + + return ( +
+

Add Nexus to your home screen

+

+ {isIOS + ? "Open the Share menu and tap 'Add to Home Screen'" + : "Get the full experience — launch instantly, works offline."} +

+
+ {!isIOS && ( + + )} + +
+
+ ); +} diff --git a/ui/src/components/OfflineBanner.tsx b/ui/src/components/OfflineBanner.tsx new file mode 100644 index 00000000..516d3d94 --- /dev/null +++ b/ui/src/components/OfflineBanner.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; +import { WifiOff } from "lucide-react"; +import { useOnlineStatus } from "../hooks/useOnlineStatus"; + +interface OfflineBannerProps { + queuedCount?: number; +} + +/** + * Amber offline status banner shown at the top of the screen when offline. + * Auto-dismisses 3 seconds after reconnection when the queue is empty. + */ +export function OfflineBanner({ queuedCount = 0 }: OfflineBannerProps) { + const isOnline = useOnlineStatus(); + const [visible, setVisible] = useState(!isOnline); + + useEffect(() => { + if (!isOnline) { + setVisible(true); + return; + } + // Online transition: hide after 3s if no queued messages + if (queuedCount === 0) { + const timer = setTimeout(() => setVisible(false), 3000); + return () => clearTimeout(timer); + } + }, [isOnline, queuedCount]); + + if (!visible) return null; + + const text = + queuedCount > 0 + ? `You're offline — ${queuedCount} message${queuedCount === 1 ? "" : "s"} queued` + : "You're offline — messages will send when you reconnect"; + + return ( +
+ + {text} +
+ ); +} diff --git a/ui/src/components/PullToRefresh.test.tsx b/ui/src/components/PullToRefresh.test.tsx new file mode 100644 index 00000000..7b0fc472 --- /dev/null +++ b/ui/src/components/PullToRefresh.test.tsx @@ -0,0 +1,9 @@ +// @vitest-environment jsdom + +import { describe, it } from "vitest"; + +describe("PullToRefresh", () => { + it.todo("calls onRefresh after drag exceeds 64px threshold"); + it.todo("does not trigger when scrollTop is not 0"); + it.todo("resets pull distance on touch end below threshold"); +}); diff --git a/ui/src/hooks/useInstallPrompt.test.ts b/ui/src/hooks/useInstallPrompt.test.ts new file mode 100644 index 00000000..54c3e93e --- /dev/null +++ b/ui/src/hooks/useInstallPrompt.test.ts @@ -0,0 +1,8 @@ +import { describe, it } from "vitest"; + +describe("useInstallPrompt", () => { + it.todo("captures beforeinstallprompt event"); + it.todo("returns canInstall=true when event captured and not standalone"); + it.todo("returns canInstall=false when already installed (standalone)"); + it.todo("calls prompt() on the deferred event when promptInstall is called"); +}); diff --git a/ui/src/hooks/useInstallPrompt.ts b/ui/src/hooks/useInstallPrompt.ts new file mode 100644 index 00000000..0cd345d4 --- /dev/null +++ b/ui/src/hooks/useInstallPrompt.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useState } from "react"; +import type { BeforeInstallPromptEvent } from "../types/pwa"; + +/** + * Captures the beforeinstallprompt event and exposes install capability. + * Also detects iOS devices where the native install prompt is unavailable. + */ +export function useInstallPrompt(): { + canInstall: boolean; + promptInstall: () => Promise; + isIOS: boolean; +} { + const [deferredPrompt, setDeferredPrompt] = + useState(null); + + // Detect iOS Safari (excludes Chrome on iOS which uses CriOS) + const isIOS = + typeof navigator !== "undefined" && + /iPhone|iPad/.test(navigator.userAgent) && + !/CriOS/.test(navigator.userAgent); + + // Check if already installed (running in standalone mode) + const isInstalled = + typeof window !== "undefined" && + window.matchMedia("(display-mode: standalone)").matches; + + useEffect(() => { + const handler = (e: BeforeInstallPromptEvent) => { + e.preventDefault(); + setDeferredPrompt(e); + }; + + window.addEventListener("beforeinstallprompt", handler); + return () => window.removeEventListener("beforeinstallprompt", handler); + }, []); + + const promptInstall = useCallback(async () => { + if (!deferredPrompt) return; + await deferredPrompt.prompt(); + await deferredPrompt.userChoice; + setDeferredPrompt(null); + }, [deferredPrompt]); + + const canInstall = !!deferredPrompt && !isInstalled; + + return { canInstall, promptInstall, isIOS }; +} diff --git a/ui/src/hooks/useOfflineQueue.test.ts b/ui/src/hooks/useOfflineQueue.test.ts new file mode 100644 index 00000000..872e087f --- /dev/null +++ b/ui/src/hooks/useOfflineQueue.test.ts @@ -0,0 +1,8 @@ +import { describe, it } from "vitest"; + +describe("useOfflineQueue", () => { + it.todo("enqueues message when offline"); + it.todo("flushes queue on online event"); + it.todo("stops flushing on first failed POST"); + it.todo("returns queued message count"); +}); diff --git a/ui/src/hooks/useOfflineQueue.ts b/ui/src/hooks/useOfflineQueue.ts new file mode 100644 index 00000000..97f8090a --- /dev/null +++ b/ui/src/hooks/useOfflineQueue.ts @@ -0,0 +1,96 @@ +import { openDB } from "idb"; +import { useCallback, useEffect, useState } from "react"; +import { chatApi } from "../api/chat"; + +const DB_NAME = "nexus-offline"; +const STORE = "message_queue"; + +interface QueueEntry { + conversationId: string; + content: string; + queuedAt: number; +} + +async function getDb() { + return openDB(DB_NAME, 1, { + upgrade(db) { + db.createObjectStore(STORE, { autoIncrement: true }); + }, + }); +} + +/** + * Offline message queue backed by IndexedDB. + * Enqueues messages when offline; auto-flushes when the online event fires. + */ +export function useOfflineQueue(): { + enqueue: (conversationId: string, content: string) => Promise; + flush: () => Promise; + queuedCount: number; +} { + const [queuedCount, setQueuedCount] = useState(0); + + // On mount, read current queue count from IndexedDB + useEffect(() => { + let cancelled = false; + getDb() + .then((db) => db.count(STORE)) + .then((count) => { + if (!cancelled) setQueuedCount(count); + }) + .catch(() => { + // IndexedDB unavailable (SSR or private browsing edge case) — ignore + }); + return () => { + cancelled = true; + }; + }, []); + + const flush = useCallback(async () => { + let db; + try { + db = await getDb(); + } catch { + return; + } + + const allKeys = await db.getAllKeys(STORE); + for (const key of allKeys) { + const entry = (await db.get(STORE, key)) as QueueEntry | undefined; + if (!entry) continue; + try { + await chatApi.postMessage(entry.conversationId, { + role: "user", + content: entry.content, + }); + await db.delete(STORE, key); + setQueuedCount((c) => Math.max(0, c - 1)); + } catch { + // Stop flushing on first failure — retry next time the online event fires + break; + } + } + }, []); + + const enqueue = useCallback( + async (conversationId: string, content: string) => { + let db; + try { + db = await getDb(); + } catch { + return; + } + await db.add(STORE, { conversationId, content, queuedAt: Date.now() }); + setQueuedCount((c) => c + 1); + }, + [], + ); + + // Auto-flush when reconnected + useEffect(() => { + window.addEventListener("online", flush); + return () => window.removeEventListener("online", flush); + }, [flush]); + + return { enqueue, flush, queuedCount }; +} diff --git a/ui/src/hooks/useOnlineStatus.ts b/ui/src/hooks/useOnlineStatus.ts new file mode 100644 index 00000000..1c3f600f --- /dev/null +++ b/ui/src/hooks/useOnlineStatus.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +/** + * Reactive online/offline status hook. + * Returns true when navigator.onLine is true (and updates reactively). + */ +export function useOnlineStatus(): boolean { + const [isOnline, setIsOnline] = useState(() => + typeof navigator !== "undefined" ? navigator.onLine : true, + ); + + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + + return isOnline; +} diff --git a/ui/src/hooks/usePushNotifications.test.ts b/ui/src/hooks/usePushNotifications.test.ts new file mode 100644 index 00000000..17039528 --- /dev/null +++ b/ui/src/hooks/usePushNotifications.test.ts @@ -0,0 +1,7 @@ +import { describe, it } from "vitest"; + +describe("usePushNotifications", () => { + it.todo("subscribes when permission is granted"); + it.todo("does not subscribe when permission is denied"); + it.todo("sends subscription to server via POST /api/push/subscribe"); +}); diff --git a/ui/src/types/pwa.d.ts b/ui/src/types/pwa.d.ts new file mode 100644 index 00000000..c1701964 --- /dev/null +++ b/ui/src/types/pwa.d.ts @@ -0,0 +1,16 @@ +/** + * Type declarations for Progressive Web App (PWA) browser APIs + * not yet present in TypeScript's default lib.dom.d.ts. + */ + +export interface BeforeInstallPromptEvent extends Event { + readonly platforms: string[]; + readonly userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>; + prompt(): Promise; +} + +declare global { + interface WindowEventMap { + beforeinstallprompt: BeforeInstallPromptEvent; + } +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index e0d8acb4..ab33ce9e 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -6,20 +6,25 @@ import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { - alias: [ - { find: "@", replacement: path.resolve(__dirname, "./src") }, - { - find: "lexical", - replacement: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"), + alias: { + "@": path.resolve(__dirname, "./src"), + lexical: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"), + // [nexus] Replace upstream OnboardingWizard with Nexus single-step version + [path.resolve(__dirname, "src/components/OnboardingWizard")]: + path.resolve(__dirname, "./src/components/NexusOnboardingWizard"), + }, + }, + build: { + rollupOptions: { + output: { + manualChunks: { + "vendor-react": ["react", "react-dom"], + "vendor-router": ["react-router-dom"], + "vendor-query": ["@tanstack/react-query"], + "vendor-markdown": ["react-markdown", "remark-gfm"], + }, }, - // [nexus] Replace upstream OnboardingWizard with Nexus single-step version. - // RegExp required: string keys match the RAW import specifier (not resolved path). - // App.tsx imports './components/OnboardingWizard' — must match exactly. - { - find: /^\.\/components\/OnboardingWizard$/, - replacement: path.resolve(__dirname, "./src/components/NexusOnboardingWizard"), - }, - ], + }, }, server: { port: 5173,