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.
- openOnboarding()}>{`New ${VOCAB.company}`}
+ openOnboarding()}>New 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 && (
+
+ Add to Home Screen
+
+ )}
+
+ Not now
+
+
+
+ );
+}
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,