feat(nexus): nexus-first navigation and first-run onboarding trigger

Stop showing Paperclip's board UI by default. First-time users now
land on Personal Assistant (v1.5), see a Nexus-first sidebar, and the
NexusOnboardingWizard (built in v1.5) actually fires on first run
instead of sitting behind a dead "Start Onboarding" button click.

App.tsx
  - CompanyRootRedirect now reads useNexusMode() and lands the user
    at /${prefix}/assistant by default. Only project_builder mode
    lands at /${prefix}/dashboard. "personal_ai" and "both" (the
    default) both go to the Assistant.
  - NoCompaniesStartPage gutted: the old "Create your first company"
    button is gone. Single-workspace mode doesn't ask users to name
    workspaces; the onboarding wizard handles it. Replaced with a
    minimal "Setting up your workspace..." loading shim.
  - OnboardingRoutePage now auto-opens the wizard on mount when no
    companies exist. Closes the dead-button gap: previously the user
    had to click "Start Onboarding" to actually get the wizard; now
    the wizard opens itself as soon as they land.

Sidebar.tsx
  - Restructured around two mode-gated sections:
    * Always visible (Nexus essentials): Assistant, Content Studio,
      Convert, Inbox, Skills, Settings. Plus the New Issue button and
      plugin sidebar items.
    * project_builder-only: Work (Issues, Routines, Goals), Projects,
      Agents, and the remaining Workspace items (Org, Costs, Activity).
  - Top bar no longer renders a company switcher dropdown — single-
    workspace mode shows the workspace name as a static label with
    the search button beside it.
  - Dashboard link removed from the always-visible section. The
    default landing is /assistant; users who explicitly want the
    Paperclip dashboard can type the URL or switch to project_builder
    mode.

Layout.tsx
  - Removed both <CompanyRail /> renderings (mobile and desktop
    branches). Single-workspace mode doesn't need a multi-company
    icon rail. Import preserved with a [nexus] comment for upstream
    rebase compat.
  - Onboarding useEffect's authenticated-mode gate removed (root
    cause of the v1.5 wizard-not-firing bug on fresh DB). This
    effect is now a belt-and-suspenders fallback; the real auto-
    trigger lives in OnboardingRoutePage because Layout isn't
    actually mounted during the zero-company first-run state
    (CompanyRootRedirect navigates to /onboarding before Layout
    ever renders).

NexusOnboardingWizard.tsx
  - handleSubmit and handleStartChat both used to hardcode the post-
    creation navigation to /${prefix}/dashboard. Now mode-aware:
    project_builder lands at /dashboard, everything else lands at
    /assistant. Matches the Sidebar and CompanyRootRedirect logic —
    a fresh user never touches the Paperclip dashboard unless they
    explicitly chose project_builder during the wizard.

Not changed:
  - The Paperclip pages themselves (Dashboard, Issues, Projects,
    Agents, Org, etc.) — still present, still accessible by URL,
    still upstream-mergeable. Just hidden from the default nav.
  - CompanyRail.tsx, CompanySwitcher.tsx, NewCompanyDialog — files
    preserved for upstream rebase diff minimization. No call sites
    remain.
  - /NEX/companies route still registered in boardRoutes(), just
    unlinked from the default UI.

TypeScript: zero new errors (pre-existing errors in AgentConfigForm,
command.tsx, useKeyboardShortcuts, usePiperTts, useVadRecorder,
OnboardingSummaryStep.test, PersonalAssistant unchanged).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-10 18:19:14 +00:00
parent 91530b07a4
commit d478cc3daf
4 changed files with 89 additions and 46 deletions

View file

@ -11,6 +11,7 @@ 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 { loadLastInboxTab } from "./lib/inbox";
import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
@ -233,13 +234,29 @@ function LegacySettingsRedirect() {
}
function OnboardingRoutePage() {
const { companies } = useCompany();
const { openOnboarding } = useDialog();
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
@ -274,9 +291,10 @@ function OnboardingRoutePage() {
function CompanyRootRedirect() {
const { companies, selectedCompany, loading } = useCompany();
const { mode, isLoading: nexusModeLoading } = useNexusMode();
const location = useLocation();
if (loading) {
if (loading || nexusModeLoading) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
}
@ -293,7 +311,11 @@ function CompanyRootRedirect() {
return <NoCompaniesStartPage />;
}
return <Navigate to={`/${targetCompany.issuePrefix}/dashboard`} replace />;
// [nexus] Nexus-first landing: in personal_ai / both (default) modes, land
// on the Personal Assistant. Only project_builder mode lands on the board
// dashboard. URL overrides (typing /PREFIX/dashboard) are still honored.
const landingPath = mode === "project_builder" ? "dashboard" : "assistant";
return <Navigate to={`/${targetCompany.issuePrefix}/${landingPath}`} replace />;
}
function UnprefixedBoardRedirect() {
@ -326,19 +348,13 @@ function UnprefixedBoardRedirect() {
}
function NoCompaniesStartPage() {
const { openOnboarding } = useDialog();
// [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 (
<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 company</h1>
<p className="mt-2 text-sm text-muted-foreground">
Get started by creating a company.
</p>
<div className="mt-4">
<Button onClick={() => openOnboarding()}>New Company</Button>
</div>
</div>
<div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">
Setting up your workspace
</div>
);
}

View file

@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, MessageSquare, Moon, Settings, Sun } from "lucide-react";
import { Link, Navigate, Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
import { CompanyRail } from "./CompanyRail";
// [nexus] CompanyRail intentionally not rendered — single-workspace mode.
// The file is preserved for upstream rebase compatibility.
import { Sidebar } from "./Sidebar";
import { InstanceSidebar } from "./InstanceSidebar";
import { BreadcrumbBar } from "./BreadcrumbBar";
@ -96,14 +97,23 @@ export function Layout() {
queryFn: () => instanceSettingsApi.getGeneral(),
}).data?.keyboardShortcuts === true;
// [nexus] Removed the `health?.deploymentMode === "authenticated"` gate.
// Paperclip's upstream assumed authenticated mode = hosted multi-tenant where
// users self-onboard via invites, so the first-run wizard was suppressed.
// Nexus's authenticated mode is single-user LAN (BETTER_AUTH), which still
// needs the first-run wizard. Note: this effect is mostly dead code for the
// zero-company case because CompanyRootRedirect navigates to /onboarding
// before Layout ever mounts — the real auto-open trigger lives in
// OnboardingRoutePage (App.tsx). This effect is retained as a belt-and-
// suspenders fallback for edge cases where a user reaches a Layout-mounted
// route with zero companies (e.g. /instance/settings/*).
useEffect(() => {
if (companiesLoading || onboardingTriggered.current) return;
if (health?.deploymentMode === "authenticated") return;
if (companies.length === 0) {
onboardingTriggered.current = true;
openOnboarding();
}
}, [companies, companiesLoading, openOnboarding, health?.deploymentMode]);
}, [companies, companiesLoading, openOnboarding]);
useEffect(() => {
if (!companyPrefix || companiesLoading || companies.length === 0) return;
@ -314,7 +324,6 @@ export function Layout() {
)}
>
<div className="flex flex-1 min-h-0 overflow-hidden">
<CompanyRail />
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
</div>
<div className="border-t border-r border-border px-3 py-2 bg-background">
@ -365,7 +374,6 @@ export function Layout() {
) : (
<div className="flex h-full flex-col shrink-0">
<div className="flex flex-1 min-h-0">
<CompanyRail />
<div
className={cn(
"overflow-hidden transition-[width] duration-100 ease-out",

View file

@ -280,9 +280,14 @@ export function OnboardingWizard() {
try {
const company = await createWorkspace();
// Navigate to dashboard — not an issue detail page
// [nexus] Mode-aware landing: personal_ai and "both" land on the
// Assistant (the Nexus-first experience); project_builder lands on
// the classic dashboard. Matches Sidebar.tsx and App.tsx
// CompanyRootRedirect — the user should never touch the Paperclip
// dashboard unless they explicitly chose project_builder mode.
const landingPath = selectedMode === "project_builder" ? "dashboard" : "assistant";
closeOnboarding();
navigate(`/${company.issuePrefix}/dashboard`);
navigate(`/${company.issuePrefix}/${landingPath}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Setup failed. Please try again.");
setLoading(false);
@ -303,9 +308,11 @@ export function OnboardingWizard() {
try {
const company = await createWorkspace();
// Navigate to dashboard then open chat panel
// [nexus] Mode-aware landing + chat panel open — same logic as
// handleSubmit but with the chat drawer toggled on.
const landingPath = selectedMode === "project_builder" ? "dashboard" : "assistant";
closeOnboarding();
navigate(`/${company.issuePrefix}/dashboard`);
navigate(`/${company.issuePrefix}/${landingPath}`);
setChatOpen(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Setup failed. Please try again.");

View file

@ -2,7 +2,6 @@ import {
Inbox,
CircleDot,
Target,
LayoutDashboard,
DollarSign,
History,
Search,
@ -12,6 +11,8 @@ import {
Repeat,
Settings,
Bot,
Sparkles,
RefreshCw,
} from "lucide-react";
import { VOCAB } from "@paperclipai/branding";
import { useQuery } from "@tanstack/react-query";
@ -31,7 +32,8 @@ import { PluginSlotOutlet } from "@/plugins/slots";
export function Sidebar() {
const { openNewIssue } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany();
const { isAssistantEnabled } = useNexusMode();
const { mode, isAssistantEnabled } = useNexusMode();
const showBoard = mode === "project_builder";
const inboxBadge = useInboxBadge(selectedCompanyId);
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
@ -39,7 +41,9 @@ export function Sidebar() {
enabled: !!selectedCompanyId,
refetchInterval: 10_000,
});
const liveRunCount = liveRuns?.length ?? 0;
// liveRunCount currently only surfaced when the board Dashboard item is
// visible (project_builder mode). Keep the fetch for parity.
void liveRuns;
function openSearch() {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
@ -52,7 +56,7 @@ export function Sidebar() {
return (
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
{/* Top bar: single workspace name (static) + search. No switcher. */}
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
{selectedCompany?.brandColor && (
<div
@ -61,7 +65,7 @@ export function Sidebar() {
/>
)}
<span className="flex-1 text-sm font-bold text-foreground truncate pl-1">
{selectedCompany?.name ?? `Select ${VOCAB.company.toLowerCase()}`}
{selectedCompany?.name ?? "Nexus"}
</span>
<Button
variant="ghost"
@ -74,8 +78,8 @@ export function Sidebar() {
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
{/* Nexus essentials: always visible in every mode. */}
<div className="flex flex-col gap-0.5">
{/* New Issue button aligned with nav items */}
<button
onClick={() => openNewIssue()}
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
@ -83,10 +87,11 @@ export function Sidebar() {
<SquarePen className="h-4 w-4 shrink-0" />
<span className="truncate">New Issue</span>
</button>
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
{isAssistantEnabled && (
<SidebarNavItem to="/assistant" label="Assistant" icon={Bot} />
)}
<SidebarNavItem to="/content-studio" label="Content Studio" icon={Sparkles} />
<SidebarNavItem to="/convert" label="Convert" icon={RefreshCw} />
<SidebarNavItem
to="/inbox"
label="Inbox"
@ -95,6 +100,8 @@ export function Sidebar() {
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
alert={inboxBadge.failedRuns > 0}
/>
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
<PluginSlotOutlet
slotTypes={["sidebar"]}
context={pluginContext}
@ -104,23 +111,28 @@ export function Sidebar() {
/>
</div>
<SidebarSection label="Work">
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} textBadge="Beta" textBadgeTone="amber" />
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
</SidebarSection>
{/* Board-mode sections: only visible when the user has explicitly
chosen project_builder mode. Hidden in personal_ai and both
(default) so first-time users never see the board chrome. */}
{showBoard && (
<>
<SidebarSection label="Work">
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} textBadge="Beta" textBadgeTone="amber" />
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
</SidebarSection>
<SidebarProjects />
<SidebarProjects />
<SidebarAgents />
<SidebarAgents />
<SidebarSection label={VOCAB.company}>
<SidebarNavItem to="/org" label="Org" icon={Network} />
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
<SidebarNavItem to="/activity" label="Activity" icon={History} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
</SidebarSection>
<SidebarSection label={VOCAB.company}>
<SidebarNavItem to="/org" label="Org" icon={Network} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
<SidebarNavItem to="/activity" label="Activity" icon={History} />
</SidebarSection>
</>
)}
<PluginSlotOutlet
slotTypes={["sidebarPanel"]}