feat(26): merge worktree code from plans 26-00, 26-01, 26-03

SW cache-first rewrite, React.lazy code splitting, PWA types/test stubs,
install prompt, offline banner, offline queue, ChatPanel wiring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-02 02:25:31 +00:00
parent d4c98016d7
commit 471a9daaa6
14 changed files with 538 additions and 132 deletions

View file

@ -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));
});

View file

@ -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 (
<div className="mx-auto max-w-xl py-10">
@ -128,8 +129,7 @@ function boardRoutes() {
<Route path="company/settings" element={<CompanySettings />} />
<Route path="company/export/*" element={<CompanyExport />} />
<Route path="company/import" element={<CompanyImport />} />
<Route path="skills" element={<SkillBrowser />} />
<Route path="skills/detail/:skillId" element={<SkillDetail />} />
<Route path="skills/*" element={<CompanySkills />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="plugins/:pluginId" element={<PluginPage />} />
@ -148,8 +148,6 @@ function boardRoutes() {
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
<Route path="projects/:projectId/workspaces/:workspaceId" element={<ProjectWorkspaceDetail />} />
<Route path="projects/:projectId/workspaces" element={<ProjectDetail />} />
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
<Route path="issues" element={<Issues />} />
@ -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 (
<div className="mx-auto max-w-xl py-10">
@ -292,12 +290,12 @@ function NoCompaniesStartPage() {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-xl font-semibold">{`Create your first ${VOCAB.company.toLowerCase()}`}</h1>
<h1 className="text-xl font-semibold">Create your first company</h1>
<p className="mt-2 text-sm text-muted-foreground">
{`Get started by creating a ${VOCAB.company.toLowerCase()}.`}
Get started by creating a company.
</p>
<div className="mt-4">
<Button onClick={() => openOnboarding()}>{`New ${VOCAB.company}`}</Button>
<Button onClick={() => openOnboarding()}>New Company</Button>
</div>
</div>
</div>
@ -307,53 +305,52 @@ function NoCompaniesStartPage() {
export function App() {
return (
<>
<Routes>
<Route path="auth" element={<AuthPage />} />
<Route path="board-claim/:token" element={<BoardClaimPage />} />
<Route path="cli-auth/:id" element={<CliAuthPage />} />
<Route path="invite/:token" element={<InviteLandingPage />} />
<Suspense fallback={<div className="flex items-center justify-center h-full"><Skeleton className="h-8 w-48" /></div>}>
<Routes>
<Route path="auth" element={<AuthPage />} />
<Route path="board-claim/:token" element={<BoardClaimPage />} />
<Route path="cli-auth/:id" element={<CliAuthPage />} />
<Route path="invite/:token" element={<InviteLandingPage />} />
<Route element={<CloudAccessGate />}>
<Route index element={<CompanyRootRedirect />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="instance" element={<Navigate to="/instance/settings/general" replace />} />
<Route path="instance/settings" element={<Layout />}>
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<InstanceGeneralSettings />} />
<Route path="heartbeats" element={<InstanceSettings />} />
<Route path="experimental" element={<InstanceExperimentalSettings />} />
<Route path="plugins" element={<PluginManager />} />
<Route path="plugins/:pluginId" element={<PluginSettings />} />
<Route element={<CloudAccessGate />}>
<Route index element={<CompanyRootRedirect />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="instance" element={<Navigate to="/instance/settings/general" replace />} />
<Route path="instance/settings" element={<Layout />}>
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<InstanceGeneralSettings />} />
<Route path="heartbeats" element={<InstanceSettings />} />
<Route path="experimental" element={<InstanceExperimentalSettings />} />
<Route path="plugins" element={<PluginManager />} />
<Route path="plugins/:pluginId" element={<PluginSettings />} />
</Route>
<Route path="companies" element={<UnprefixedBoardRedirect />} />
<Route path="issues" element={<UnprefixedBoardRedirect />} />
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
<Route path="routines" element={<UnprefixedBoardRedirect />} />
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
<Route path="skills/*" element={<UnprefixedBoardRedirect />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="agents" element={<UnprefixedBoardRedirect />} />
<Route path="agents/new" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId/:tab" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId/runs/:runId" element={<UnprefixedBoardRedirect />} />
<Route path="projects" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/overview" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
<Route path=":companyPrefix" element={<Layout />}>
{boardRoutes()}
</Route>
<Route path="*" element={<NotFoundPage scope="global" />} />
</Route>
<Route path="companies" element={<UnprefixedBoardRedirect />} />
<Route path="issues" element={<UnprefixedBoardRedirect />} />
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
<Route path="routines" element={<UnprefixedBoardRedirect />} />
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
<Route path="skills/*" element={<UnprefixedBoardRedirect />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="agents" element={<UnprefixedBoardRedirect />} />
<Route path="agents/new" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId/:tab" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId/runs/:runId" element={<UnprefixedBoardRedirect />} />
<Route path="projects" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/overview" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/workspaces" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
<Route path=":companyPrefix" element={<Layout />}>
{boardRoutes()}
</Route>
<Route path="*" element={<NotFoundPage scope="global" />} />
</Route>
</Routes>
</Routes>
</Suspense>
<OnboardingWizard />
</>
);

View file

@ -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 */}
<OfflineBanner queuedCount={queuedCount} />
{/* Header with agent selector */}
<div className="flex items-center justify-between border-b border-border px-4 py-2 min-w-[380px]">
<span className="text-sm font-medium">Chat</span>
@ -409,6 +427,9 @@ export function ChatPanel() {
companyId={selectedCompanyId}
onNavigate={handleSearchNavigate}
/>
{/* PWA install prompt banner (self-contained show/hide logic) */}
<InstallPromptBanner />
</aside>
);
}

View file

@ -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 (
<div
className="fixed bottom-16 left-4 right-4 z-50 md:bottom-auto md:top-4 md:left-auto md:right-4 md:max-w-sm bg-card border border-border rounded-lg shadow-lg p-4"
role="banner"
aria-label="Install Nexus app"
>
<p className="text-sm font-semibold mb-1">Add Nexus to your home screen</p>
<p className="text-xs text-muted-foreground mb-3">
{isIOS
? "Open the Share menu and tap 'Add to Home Screen'"
: "Get the full experience — launch instantly, works offline."}
</p>
<div className="flex items-center gap-2">
{!isIOS && (
<Button size="sm" onClick={handleInstall}>
Add to Home Screen
</Button>
)}
<Button variant="ghost" size="sm" onClick={handleDismiss}>
Not now
</Button>
</div>
</div>
);
}

View file

@ -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 (
<div
className="fixed top-0 left-0 right-0 z-50 px-4 py-2 text-sm flex items-center gap-2 bg-amber-50 text-amber-800 border-b border-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:border-amber-800"
role="status"
aria-live="polite"
>
<WifiOff className="h-4 w-4 flex-shrink-0" />
<span>{text}</span>
</div>
);
}

View file

@ -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");
});

View file

@ -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");
});

View file

@ -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<void>;
isIOS: boolean;
} {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(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 };
}

View file

@ -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");
});

View file

@ -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<void>;
flush: () => Promise<void>;
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 };
}

View file

@ -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;
}

View file

@ -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");
});

16
ui/src/types/pwa.d.ts vendored Normal file
View file

@ -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<void>;
}
declare global {
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent;
}
}

View file

@ -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,