import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router"; import { VOCAB } from "@paperclipai/branding"; import { useQuery } from "@tanstack/react-query"; import { lazy, Suspense, useEffect } 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 { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; import { useNexusMode } from "./hooks/useNexusMode"; import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route"; 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 IssueDetail = lazy(() => import("./pages/IssueDetail").then(m => ({ default: m.IssueDetail }))); const ExecutionWorkspaceDetail = lazy(() => import("./pages/ExecutionWorkspaceDetail").then(m => ({ default: m.ExecutionWorkspaceDetail }))); 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 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 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 }))); const PersonalAssistant = lazy(() => import("./pages/PersonalAssistant").then(m => ({ default: m.PersonalAssistant }))); const ContentStudio = lazy(() => import("./pages/ContentStudio").then(m => ({ default: m.ContentStudio }))); function BootstrapPendingPage({ hasActiveInvite = false, invitePath, }: { hasActiveInvite?: boolean; invitePath?: string; }) { // [nexus] Zero-terminal first boot: the server auto-generates a // bootstrap_ceo invite on startup when no admin exists and exposes its // relative path via /api/health. Redirect straight to /invite/{token} // so the first user never sees a CLI command. useEffect(() => { if (invitePath) { window.location.replace(invitePath); } }, [invitePath]); if (invitePath) { return (
Setting up instance — redirecting to admin account creation…
); } // Fallback for headless/SSH-only deployments where the server couldn't // auto-generate an invite (e.g. startup error). The CLI command still // works in that case. return (

Instance setup required

{hasActiveInvite ? `No instance admin exists yet. A bootstrap invite is already active. Check your ${VOCAB.appName} startup logs for the first admin invite URL, or run this command to rotate it:` : `No instance admin exists yet. Run this command in your ${VOCAB.appName} environment to generate the first admin invite URL:`}

{`pnpm paperclipai auth bootstrap-ceo`}
        
); } function CloudAccessGate() { const location = useLocation(); const healthQuery = useQuery({ queryKey: queryKeys.health, queryFn: () => healthApi.get(), retry: false, refetchInterval: (query) => { const data = query.state.data as | { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" } | undefined; return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending" ? 2000 : false; }, refetchIntervalInBackground: true, }); const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated"; const sessionQuery = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), enabled: isAuthenticatedMode, retry: false, }); if (healthQuery.isLoading || (isAuthenticatedMode && sessionQuery.isLoading)) { return
Loading...
; } if (healthQuery.error) { return (
{healthQuery.error instanceof Error ? healthQuery.error.message : "Failed to load app state"}
); } if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") { return ( ); } if (isAuthenticatedMode && !sessionQuery.data) { const next = encodeURIComponent(`${location.pathname}${location.search}`); return ; } return ; } function boardRoutes() { return ( <> } /> } /> } /> } /> } /> } /> } /> } /> } /> {/* Phase 16b: /agents top-level redirects to /projects. Agent detail pages remain. */} } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> {/* Phase 16b: /issues top-level redirects to /projects. Issue detail pages remain. */} } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> {/* Phase 16b: /convert folded into Studio Convert workshop. */} } /> } /> } /> } /> } /> } /> } /> ); } function LegacySettingsRedirect() { const location = useLocation(); return ; } function OnboardingRoutePage() { const { companies, loading: companiesLoading } = useCompany(); const { openOnboarding, onboardingOpen } = useDialog(); const { companyPrefix } = useParams<{ companyPrefix?: string }>(); const matchedCompany = companyPrefix ? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null : null; // [nexus] Auto-open the first-run wizard when a user lands on /onboarding // with zero companies. CompanyRootRedirect navigates here on fresh DB, and // without this effect the user would see the fallback "Start Onboarding" // button and have to click it. The wizard itself already renders via its // URL-based trigger (routeOnboardingOptions), but we also flip the dialog // state so that post-wizard-creation navigation (which closes via // closeOnboarding) works consistently. The button below is retained as a // harmless fallback for the "add another" flows. useEffect(() => { if (companiesLoading) return; if (onboardingOpen) return; if (companies.length === 0 && !companyPrefix) { openOnboarding(); } }, [companies.length, companiesLoading, companyPrefix, onboardingOpen, openOnboarding]); const title = matchedCompany ? `Add another agent to ${matchedCompany.name}` : companies.length > 0 ? "Create another workspace" : "Create your first workspace"; const description = matchedCompany ? "Run onboarding again to add an agent and a starter task for this workspace." : companies.length > 0 ? "Run onboarding again to create another workspace and seed its first agent." : "Get started by creating a workspace and your first agent."; return (

{title}

{description}

); } function CompanyRootRedirect() { const { companies, selectedCompany, loading } = useCompany(); const { mode, isLoading: nexusModeLoading } = useNexusMode(); const location = useLocation(); if (loading || nexusModeLoading) { return
Loading...
; } const targetCompany = selectedCompany ?? companies[0] ?? null; if (!targetCompany) { if ( shouldRedirectCompanylessRouteToOnboarding({ pathname: location.pathname, hasCompanies: false, }) ) { return ; } return ; } // [nexus] Nexus-first landing: in personal_ai / both (default) modes, land // on the Personal Assistant. project_builder mode lands on projects. The // legacy dashboard route is deleted in Phase 16b. const landingPath = mode === "project_builder" ? "projects" : "assistant"; return ; } function UnprefixedBoardRedirect() { const location = useLocation(); const { companies, selectedCompany, loading } = useCompany(); if (loading) { return
Loading...
; } const targetCompany = selectedCompany ?? companies[0] ?? null; if (!targetCompany) { if ( shouldRedirectCompanylessRouteToOnboarding({ pathname: location.pathname, hasCompanies: false, }) ) { return ; } return ; } return ( ); } function NoCompaniesStartPage() { // [nexus] Single-workspace mode: there is no user-facing "create company" // action. The onboarding wizard is the only path to initial workspace // creation and is triggered separately. Show a minimal loading shim while // that flow runs. return (
Setting up your workspace…
); } export function App() { return ( <> }> } /> } /> } /> } /> }> } /> } /> } /> }> } /> } /> {/* Phase 13: nested settings sub-routes collapse into /general. */} } /> } /> {/* Plugins remain top-level pages owned by the plugin system, not settings sub-pages. */} } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> }> {boardRoutes()} } /> ); }