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:
parent
91530b07a4
commit
d478cc3daf
4 changed files with 89 additions and 46 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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"]}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue