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:
parent
d4c98016d7
commit
471a9daaa6
14 changed files with 538 additions and 132 deletions
|
|
@ -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();
|
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) => {
|
self.addEventListener("activate", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((keys) =>
|
caches
|
||||||
Promise.all(keys.map((key) => caches.delete(key)))
|
.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) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
const { request } = event;
|
const { request } = event;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
// Skip non-GET requests and API calls
|
// API calls — network only, no interception
|
||||||
if (request.method !== "GET" || url.pathname.startsWith("/api")) {
|
if (url.pathname.startsWith("/api")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network-first for everything — cache is only an offline fallback
|
// Navigation requests — cache-first: serve shell from cache, fall back to network
|
||||||
event.respondWith(
|
if (request.mode === "navigate") {
|
||||||
fetch(request)
|
event.respondWith(
|
||||||
.then((response) => {
|
caches.match("/").then((cached) => cached || fetch(request))
|
||||||
if (response.ok && url.origin === self.location.origin) {
|
);
|
||||||
const clone = response.clone();
|
return;
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
}
|
||||||
}
|
|
||||||
return response;
|
// Static assets — cache-first: serve from cache, on miss fetch and cache
|
||||||
})
|
if (STATIC_EXTENSION_RE.test(url.pathname)) {
|
||||||
.catch(() => {
|
event.respondWith(
|
||||||
if (request.mode === "navigate") {
|
caches.match(request).then((cached) => {
|
||||||
return caches.match("/") || new Response("Offline", { status: 503 });
|
if (cached) return cached;
|
||||||
}
|
return fetch(request).then((response) => {
|
||||||
return caches.match(request);
|
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));
|
||||||
|
});
|
||||||
|
|
|
||||||
189
ui/src/App.tsx
189
ui/src/App.tsx
|
|
@ -1,56 +1,57 @@
|
||||||
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
|
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
|
||||||
import { VOCAB } from "@paperclipai/branding";
|
import { VOCAB } from "@paperclipai/branding";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { lazy, Suspense } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Layout } from "./components/Layout";
|
import { Layout } from "./components/Layout";
|
||||||
import { OnboardingWizard } from "./components/OnboardingWizard";
|
import { OnboardingWizard } from "./components/OnboardingWizard";
|
||||||
import { authApi } from "./api/auth";
|
import { authApi } from "./api/auth";
|
||||||
import { healthApi } from "./api/health";
|
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 { queryKeys } from "./lib/queryKeys";
|
||||||
import { useCompany } from "./context/CompanyContext";
|
import { useCompany } from "./context/CompanyContext";
|
||||||
import { useDialog } from "./context/DialogContext";
|
import { useDialog } from "./context/DialogContext";
|
||||||
import { loadLastInboxTab } from "./lib/inbox";
|
import { loadLastInboxTab } from "./lib/inbox";
|
||||||
import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
|
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 }) {
|
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl py-10">
|
<div className="mx-auto max-w-xl py-10">
|
||||||
|
|
@ -128,8 +129,7 @@ function boardRoutes() {
|
||||||
<Route path="company/settings" element={<CompanySettings />} />
|
<Route path="company/settings" element={<CompanySettings />} />
|
||||||
<Route path="company/export/*" element={<CompanyExport />} />
|
<Route path="company/export/*" element={<CompanyExport />} />
|
||||||
<Route path="company/import" element={<CompanyImport />} />
|
<Route path="company/import" element={<CompanyImport />} />
|
||||||
<Route path="skills" element={<SkillBrowser />} />
|
<Route path="skills/*" element={<CompanySkills />} />
|
||||||
<Route path="skills/detail/:skillId" element={<SkillDetail />} />
|
|
||||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="plugins/:pluginId" element={<PluginPage />} />
|
<Route path="plugins/:pluginId" element={<PluginPage />} />
|
||||||
|
|
@ -148,8 +148,6 @@ function boardRoutes() {
|
||||||
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
|
||||||
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
|
||||||
<Route path="projects/:projectId/issues/:filter" 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/configuration" element={<ProjectDetail />} />
|
||||||
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
|
||||||
<Route path="issues" element={<Issues />} />
|
<Route path="issues" element={<Issues />} />
|
||||||
|
|
@ -204,13 +202,13 @@ function OnboardingRoutePage() {
|
||||||
const title = matchedCompany
|
const title = matchedCompany
|
||||||
? `Add another agent to ${matchedCompany.name}`
|
? `Add another agent to ${matchedCompany.name}`
|
||||||
: companies.length > 0
|
: companies.length > 0
|
||||||
? `Create another ${VOCAB.company.toLowerCase()}`
|
? "Create another company"
|
||||||
: `Create your first ${VOCAB.company.toLowerCase()}`;
|
: "Create your first company";
|
||||||
const description = matchedCompany
|
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
|
: companies.length > 0
|
||||||
? `Run onboarding again to create another ${VOCAB.company.toLowerCase()} and seed its first agent.`
|
? "Run onboarding again to create another company and seed its first agent."
|
||||||
: `Get started by creating a ${VOCAB.company.toLowerCase()} and your first agent.`;
|
: "Get started by creating a company and your first agent.";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl py-10">
|
<div className="mx-auto max-w-xl py-10">
|
||||||
|
|
@ -292,12 +290,12 @@ function NoCompaniesStartPage() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl py-10">
|
<div className="mx-auto max-w-xl py-10">
|
||||||
<div className="rounded-lg border border-border bg-card p-6">
|
<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">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{`Get started by creating a ${VOCAB.company.toLowerCase()}.`}
|
Get started by creating a company.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Button onClick={() => openOnboarding()}>{`New ${VOCAB.company}`}</Button>
|
<Button onClick={() => openOnboarding()}>New Company</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -307,53 +305,52 @@ function NoCompaniesStartPage() {
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Routes>
|
<Suspense fallback={<div className="flex items-center justify-center h-full"><Skeleton className="h-8 w-48" /></div>}>
|
||||||
<Route path="auth" element={<AuthPage />} />
|
<Routes>
|
||||||
<Route path="board-claim/:token" element={<BoardClaimPage />} />
|
<Route path="auth" element={<AuthPage />} />
|
||||||
<Route path="cli-auth/:id" element={<CliAuthPage />} />
|
<Route path="board-claim/:token" element={<BoardClaimPage />} />
|
||||||
<Route path="invite/:token" element={<InviteLandingPage />} />
|
<Route path="cli-auth/:id" element={<CliAuthPage />} />
|
||||||
|
<Route path="invite/:token" element={<InviteLandingPage />} />
|
||||||
|
|
||||||
<Route element={<CloudAccessGate />}>
|
<Route element={<CloudAccessGate />}>
|
||||||
<Route index element={<CompanyRootRedirect />} />
|
<Route index element={<CompanyRootRedirect />} />
|
||||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
||||||
<Route path="instance" element={<Navigate to="/instance/settings/general" replace />} />
|
<Route path="instance" element={<Navigate to="/instance/settings/general" replace />} />
|
||||||
<Route path="instance/settings" element={<Layout />}>
|
<Route path="instance/settings" element={<Layout />}>
|
||||||
<Route index element={<Navigate to="general" replace />} />
|
<Route index element={<Navigate to="general" replace />} />
|
||||||
<Route path="general" element={<InstanceGeneralSettings />} />
|
<Route path="general" element={<InstanceGeneralSettings />} />
|
||||||
<Route path="heartbeats" element={<InstanceSettings />} />
|
<Route path="heartbeats" element={<InstanceSettings />} />
|
||||||
<Route path="experimental" element={<InstanceExperimentalSettings />} />
|
<Route path="experimental" element={<InstanceExperimentalSettings />} />
|
||||||
<Route path="plugins" element={<PluginManager />} />
|
<Route path="plugins" element={<PluginManager />} />
|
||||||
<Route path="plugins/:pluginId" element={<PluginSettings />} />
|
<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>
|
||||||
<Route path="companies" element={<UnprefixedBoardRedirect />} />
|
</Routes>
|
||||||
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
</Suspense>
|
||||||
<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>
|
|
||||||
<OnboardingWizard />
|
<OnboardingWizard />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import { ChatSearchDialog } from "./ChatSearchDialog";
|
||||||
import { ChatBranchSelector } from "./ChatBranchSelector";
|
import { ChatBranchSelector } from "./ChatBranchSelector";
|
||||||
import { ChatBookmarkList } from "./ChatBookmarkList";
|
import { ChatBookmarkList } from "./ChatBookmarkList";
|
||||||
import { MobileChatView } from "./MobileChatView";
|
import { MobileChatView } from "./MobileChatView";
|
||||||
|
import { InstallPromptBanner } from "./InstallPromptBanner";
|
||||||
|
import { OfflineBanner } from "./OfflineBanner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { chatApi } from "../api/chat";
|
import { chatApi } from "../api/chat";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
|
|
@ -22,6 +24,8 @@ import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault";
|
||||||
import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks";
|
import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks";
|
||||||
import { useChatFileUpload } from "../hooks/useChatFileUpload";
|
import { useChatFileUpload } from "../hooks/useChatFileUpload";
|
||||||
import { useMediaQuery } from "../hooks/useMediaQuery";
|
import { useMediaQuery } from "../hooks/useMediaQuery";
|
||||||
|
import { useOfflineQueue } from "../hooks/useOfflineQueue";
|
||||||
|
import { useOnlineStatus } from "../hooks/useOnlineStatus";
|
||||||
import { resolveAgentFromContent } from "../lib/slash-commands";
|
import { resolveAgentFromContent } from "../lib/slash-commands";
|
||||||
import type { AgentRole } from "@paperclipai/shared";
|
import type { AgentRole } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
|
@ -39,6 +43,8 @@ export function ChatPanel() {
|
||||||
const { messages } = useChatMessages(activeConversationId);
|
const { messages } = useChatMessages(activeConversationId);
|
||||||
const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId);
|
const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId);
|
||||||
const { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds } = useChatFileUpload(activeConversationId);
|
const { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds } = useChatFileUpload(activeConversationId);
|
||||||
|
const { enqueue, queuedCount } = useOfflineQueue();
|
||||||
|
const isOnline = useOnlineStatus();
|
||||||
|
|
||||||
const brainstormerDefaultId = useBrainstormerDefault();
|
const brainstormerDefaultId = useBrainstormerDefault();
|
||||||
|
|
||||||
|
|
@ -134,6 +140,15 @@ export function ChatPanel() {
|
||||||
const handleSend = async (content: string) => {
|
const handleSend = async (content: string) => {
|
||||||
if (!selectedCompanyId) return;
|
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
|
// Resolve agent from slash command or @mention
|
||||||
const resolvedAgentId = resolveAgentFromContent(content, agents, activeAgentId);
|
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"
|
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 }}
|
style={{ width: chatOpen ? 380 : 0 }}
|
||||||
>
|
>
|
||||||
|
{/* Offline status banner */}
|
||||||
|
<OfflineBanner queuedCount={queuedCount} />
|
||||||
|
|
||||||
{/* Header with agent selector */}
|
{/* Header with agent selector */}
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2 min-w-[380px]">
|
<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>
|
<span className="text-sm font-medium">Chat</span>
|
||||||
|
|
@ -409,6 +427,9 @@ export function ChatPanel() {
|
||||||
companyId={selectedCompanyId}
|
companyId={selectedCompanyId}
|
||||||
onNavigate={handleSearchNavigate}
|
onNavigate={handleSearchNavigate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* PWA install prompt banner (self-contained show/hide logic) */}
|
||||||
|
<InstallPromptBanner />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
ui/src/components/InstallPromptBanner.tsx
Normal file
74
ui/src/components/InstallPromptBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
ui/src/components/OfflineBanner.tsx
Normal file
46
ui/src/components/OfflineBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
ui/src/components/PullToRefresh.test.tsx
Normal file
9
ui/src/components/PullToRefresh.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
8
ui/src/hooks/useInstallPrompt.test.ts
Normal file
8
ui/src/hooks/useInstallPrompt.test.ts
Normal 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");
|
||||||
|
});
|
||||||
47
ui/src/hooks/useInstallPrompt.ts
Normal file
47
ui/src/hooks/useInstallPrompt.ts
Normal 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 };
|
||||||
|
}
|
||||||
8
ui/src/hooks/useOfflineQueue.test.ts
Normal file
8
ui/src/hooks/useOfflineQueue.test.ts
Normal 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");
|
||||||
|
});
|
||||||
96
ui/src/hooks/useOfflineQueue.ts
Normal file
96
ui/src/hooks/useOfflineQueue.ts
Normal 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 };
|
||||||
|
}
|
||||||
26
ui/src/hooks/useOnlineStatus.ts
Normal file
26
ui/src/hooks/useOnlineStatus.ts
Normal 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;
|
||||||
|
}
|
||||||
7
ui/src/hooks/usePushNotifications.test.ts
Normal file
7
ui/src/hooks/usePushNotifications.test.ts
Normal 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
16
ui/src/types/pwa.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,20 +6,25 @@ import tailwindcss from "@tailwindcss/vite";
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: [
|
alias: {
|
||||||
{ find: "@", replacement: path.resolve(__dirname, "./src") },
|
"@": path.resolve(__dirname, "./src"),
|
||||||
{
|
lexical: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"),
|
||||||
find: "lexical",
|
// [nexus] Replace upstream OnboardingWizard with Nexus single-step version
|
||||||
replacement: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"),
|
[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: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue