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();
|
||||
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));
|
||||
});
|
||||
|
|
|
|||
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 { 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 />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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({
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue