diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md
index fff67965226..15c311c909b 100644
--- a/doc/SPEC-implementation.md
+++ b/doc/SPEC-implementation.md
@@ -995,7 +995,16 @@ On a Paperclip Cloud-managed instance, `POST /companies` returns `403` with
code `cloud_managed`; the trusted-header provisioning path and company import
routes remain the only company-creation paths there.
-## 10.1.1 Cloud Stack Portfolio
+## 10.1.1 Organization navigation and Cloud invitations
+
+Core's built-in organization switcher lists this instance's companies. An
+installed plugin can replace it through the generic `organizationSwitcher`
+slot. The built-in menu remains available when no unique usable contribution
+exists. Core does not fetch or render Cloud portfolios in its switcher. Managed
+hosts do not expose local company creation in that built-in menu; their extension
+owns the creation action.
+
+The Cloud Members-page invitation action still uses the following route:
- `GET /cloud/stacks`
diff --git a/ui/src/components/SidebarCompanyMenu.production.tsx b/ui/src/components/SidebarCompanyMenu.production.tsx
index 20d26072389..81f3bbcca7b 100644
--- a/ui/src/components/SidebarCompanyMenu.production.tsx
+++ b/ui/src/components/SidebarCompanyMenu.production.tsx
@@ -23,7 +23,6 @@ import { CSS } from "@dnd-kit/utilities";
import { hidesCompanyPage, type Company } from "@paperclipai/shared";
import { Link, useLocation, useNavigate } from "@/lib/router";
import { authApi } from "@/api/auth";
-import { cloudApi, type CloudStackSummary } from "@/api/cloud";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -38,8 +37,6 @@ import { useCloudInstance } from "@/hooks/useCloudInstance";
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
import { useCompanyOrder } from "@/hooks/useCompanyOrder";
import { useSignOut } from "@/hooks/useSignOut";
-import { navigateTopLevel } from "@/lib/browserNavigation";
-import { cloudStackCreateUrl, cloudStackEnterUrl } from "@/lib/cloudLinks";
import { queryKeys } from "@/lib/queryKeys";
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils";
import { useSidebar } from "../context/SidebarContext";
@@ -70,73 +67,6 @@ function WorkspaceIcon({ company, inPopover = false }: { company: Company; inPop
);
}
-/**
- * Cloud stacks have no hot-linkable icon URL in the portfolio payload, so v1
- * renders the same deterministic monogram treatment cloud's own portfolio page
- * uses — seeded by the display name, never fetched.
- */
-function StackIcon({ displayName }: { displayName: string }) {
- return ;
-}
-
-/**
- * The switcher trigger on a Cloud instance. A Cloud tenant holds exactly one
- * company and the harness pushes the stack's uploaded workspace icon into
- * that company's branding, so the company logo is the stack logo here.
- * The stack rows keep the monogram treatment above.
- */
-function CurrentStackIcon({
- displayName,
- company,
-}: {
- displayName: string;
- company: Company | null;
-}) {
- return (
-
- );
-}
-
-function CloudStackItem({
- stack,
- isSelected,
- onSelect,
-}: {
- stack: CloudStackSummary;
- isSelected: boolean;
- onSelect: (stack: CloudStackSummary) => void;
-}) {
- return (
- onSelect(stack)}
- className={ORGANIZATION_ROW_CLASS}
- >
-
-
-
- {stack.displayName}
-
-
- {stack.stackSlug}
-
-
-
- {isSelected ? : null}
-
-
- );
-}
-
function SortableCompanyItem({
company,
isEditing,
@@ -257,44 +187,16 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
userId: currentUserId,
});
- // In Paperclip Cloud the switcher lists the signed-in user's stacks
- // (organizations) instead of the instance's companies: a cloud instance holds
- // exactly one company, and switching means leaving this tenant host entirely.
- const cloud = useCloudInstance();
- const isCloud = Boolean(cloud);
- // The invite shortcut points at the company Invites surface, so an operator
- // that hides that surface via PAPERCLIP_HIDDEN_SETTINGS (company.invites or
- // company.members) hides this shortcut too. This is the per-deployment knob
- // Paperclip Cloud uses to drop the shortcut on its managed stacks while
- // other hosters keep it; the streamlined menu already honors it, this shell
- // was the gap. Until the health response resolves the hidden set is unknown
- // — keep the shortcut out rather than flash it.
+ // Keep invitation links hidden until the operator policy is known.
const { hidden: hiddenSettings, loaded: hiddenSettingsLoaded } = useHiddenSettings();
const showInvitePeople =
hiddenSettingsLoaded &&
!hidesCompanyPage(hiddenSettings, "company.members") &&
!hidesCompanyPage(hiddenSettings, "company.invites");
- const cloudBaseUrl = cloud?.cloudBaseUrl ?? null;
- const stacksQuery = useQuery({
- queryKey: queryKeys.cloud.stacks,
- queryFn: () => cloudApi.listStacks(),
- enabled: isCloud,
- staleTime: 30_000,
- retry: false,
- });
- const stacks = stacksQuery.data?.stacks ?? [];
- const currentStack = isCloud
- ? stacks.find((stack) => stack.isCurrent)
- ?? stacks.find((stack) => Boolean(cloud?.stackSlug) && stack.stackSlug === cloud?.stackSlug)
- ?? null
- : null;
- const createStackUrl = isCloud ? cloudStackCreateUrl(cloudBaseUrl) : null;
- const switcherNoun = isCloud ? "organization" : "company";
- // The one name the chrome shows for "where am I": the stack in cloud, the
- // company when self-hosted.
- const currentName = isCloud
- ? currentStack?.displayName ?? cloud?.stackDisplayName ?? cloud?.stackSlug ?? null
- : selectedCompany?.name ?? null;
+ const switcherNoun = "company";
+ const currentName = selectedCompany?.name ?? null;
+ // Managed hosts forbid local company creation. Their extension owns that action.
+ const companyCreationManaged = Boolean(useCloudInstance());
const signOutMutation = useSignOut({ onSignedOut: closeNavigationChrome });
@@ -325,29 +227,10 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
}
}
- /**
- * Switching stacks is a full top-level navigation, not client routing: the
- * cloud entry-code handoff authenticates the user for the target stack and
- * wakes it if it is asleep before landing on its own tenant host.
- */
- function selectStack(stack: CloudStackSummary) {
- setOpen(false);
- if (isMobile) setSidebarOpen(false);
- if (stack.stackSlug === currentStack?.stackSlug) return;
- const target = cloudStackEnterUrl(cloudBaseUrl, stack.stackSlug);
- if (!target) return;
- navigateTopLevel(target);
- }
-
function addCompany() {
+ if (companyCreationManaged) return;
setOpen(false);
if (isMobile) setSidebarOpen(false);
- // Cloud creates organizations in the cloud app; the in-app company wizard
- // is unreachable there (POST /companies is a 403 floor on managed stacks).
- if (isCloud) {
- if (createStackUrl) navigateTopLevel(createStackUrl);
- return;
- }
// Skip the front-door "how would you like to get started?" choice and land
// directly on "Name your organization" — this entry point is unambiguously
// "create a new company" (PAP-431).
@@ -381,8 +264,7 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
// `min-w-0` on every link of the flex chain (button → label row → label)
// is what lets the name truncate: a flex item's default `min-width:auto`
// floors it at its content width, so without it a long name widens the
- // trigger past the sidebar and pushes the chevron out of bounds. Company
- // names were short in practice; cloud stack names are user-chosen.
+ // trigger past the sidebar and pushes the chevron out of bounds.
className="h-9 min-w-0 flex-1 justify-start gap-2 px-4 text-left hover:bg-accent/50 hover:text-foreground has-[>svg]:px-4 dark:hover:bg-accent/50"
aria-label={
currentName
@@ -391,9 +273,7 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
}
>
- {isCloud
- ? currentName ? : null
- : selectedCompany ? : null}
+ {selectedCompany ? : null}
{/* The header has room for ~110px of name beside the collapse
control (~142px on mobile, which hides it) — search moved to
the nav to buy that width. A name that still
@@ -405,7 +285,7 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
)}
title={currentName ?? undefined}
>
- {currentName ?? (isCloud ? "Select organization" : "Select company")}
+ {currentName ?? `Select ${switcherNoun}`}
{!rail && }
@@ -420,95 +300,65 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
Organizations
- {/* Stack order is owned by cloud's own portfolio in v1, so the
- drag-to-reorder affordance stays self-hosted-only. */}
- {isCloud ? null : (
-
- )}
+
- {isCloud ? (
- <>
- {stacks.map((stack) => (
-
+ company.id)}
+ strategy={verticalListSortingStrategy}
+ >
+ {orderedCompanies.map((company) => (
+
))}
- {stacks.length === 0 ? (
-
- {stacksQuery.isLoading
- ? "Loading organizations..."
- : stacksQuery.isError
- ? "Could not load organizations"
- : "No organizations"}
-
- ) : null}
- >
- ) : (
- <>
-
- company.id)}
- strategy={verticalListSortingStrategy}
+
+
+ {orderedCompanies.length === 0 ? (
+ // "No companies" is a claim about the account. After a failed
+ // list request it is one we cannot make, and this menu is the
+ // only place the customer can act on it — say what happened and
+ // offer the way back.
+ companyListUnavailable ? (
+ <>
+ Couldn't load companies
+ {
+ // Keep the menu open so the result of the retry is visible.
+ event.preventDefault();
+ void retryCompanies();
+ }}
>
- {orderedCompanies.map((company) => (
-
- ))}
-
-
- {orderedCompanies.length === 0 ? (
- // "No companies" is a claim about the account. After a failed
- // list request it is one we cannot make, and this menu is the
- // only place the customer can act on it — say what happened and
- // offer the way back.
- companyListUnavailable ? (
- <>
- Couldn't load companies
- {
- // Keep the menu open so the result of the retry is visible.
- event.preventDefault();
- void retryCompanies();
- }}
- >
-
- Try again
-
- >
- ) : (
- No companies
- )
- ) : null}
- >
- )}
+
+ Try again
+
+ >
+ ) : (
+ No companies
+ )
+ ) : null}
- {/* A cloud instance without a configured cloud origin has nowhere to
- send the user, so the row drops out entirely. */}
- {isCloud && !createStackUrl ? null : (
+ {!companyCreationManaged && (
({
getCompanyOrder: vi.fn(),
updateCompanyOrder: vi.fn(),
}));
-const mockCloudApi = vi.hoisted(() => ({
- listStacks: vi.fn(),
-}));
const mockNavigateTopLevel = vi.hoisted(() => vi.fn());
vi.mock("@/api/auth", () => ({
authApi: mockAuthApi,
}));
-vi.mock("@/api/cloud", () => ({
- cloudApi: mockCloudApi,
-}));
-
vi.mock("@/lib/browserNavigation", () => ({
navigateTopLevel: mockNavigateTopLevel,
}));
@@ -141,29 +134,6 @@ const CLOUD_HEALTH = {
},
};
-const CLOUD_STACKS = {
- stacks: [
- {
- displayName: "Acme Labs",
- stackSlug: "acme-labs",
- primaryHost: "acme-labs.example.test",
- lifecycleState: "active",
- sleepState: "awake",
- role: "owner",
- isCurrent: true,
- },
- {
- displayName: "Strata Systems",
- stackSlug: "strata",
- primaryHost: "strata.example.test",
- lifecycleState: "active",
- sleepState: "asleep",
- role: "member",
- isCurrent: false,
- },
- ],
-};
-
describe("SidebarCompanyMenu", () => {
let container: HTMLDivElement;
@@ -179,7 +149,6 @@ describe("SidebarCompanyMenu", () => {
},
});
mockAuthApi.signOut.mockResolvedValue(undefined);
- mockCloudApi.listStacks.mockResolvedValue(CLOUD_STACKS);
mockSidebarPreferencesApi.getCompanyOrder.mockResolvedValue({
orderedIds: ["company-1", "company-2", "company-3"],
updatedAt: null,
@@ -199,7 +168,7 @@ describe("SidebarCompanyMenu", () => {
vi.clearAllMocks();
});
- function renderMenu(options: { cloud?: boolean; health?: unknown } = {}) {
+ function renderMenu(options: { cloud?: boolean; health?: unknown; production?: boolean } = {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
@@ -209,10 +178,11 @@ describe("SidebarCompanyMenu", () => {
queryClient.setQueryData(queryKeys.health, options.health ?? CLOUD_HEALTH);
}
const root = createRoot(container);
+ const Menu = options.production ? SidebarCompanyMenuProduction : SidebarCompanyMenu;
act(() => {
root.render(
-
+
,
);
});
@@ -599,7 +569,6 @@ describe("SidebarCompanyMenu", () => {
expect(mockOpenOnboarding).toHaveBeenCalledTimes(1);
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
- expect(mockCloudApi.listStacks).not.toHaveBeenCalled();
act(() => {
root.unmount();
@@ -661,168 +630,22 @@ describe("SidebarCompanyMenu", () => {
});
});
- it("switches organizations instead of companies", async () => {
- const { root } = renderMenu({ cloud: true });
+ it.each([false, true])("keeps company navigation without local creation when a managed host has no switcher plugin (production=%s)", async (production) => {
+ const { root } = renderMenu({ cloud: true, production });
await flushReact();
await flushReact();
-
- expect(mockCloudApi.listStacks).toHaveBeenCalledTimes(1);
- await openMenu("Open Acme Labs organization switcher");
-
- expect(document.body.textContent).toContain("Organizations");
- expect(document.body.textContent).toContain("Create organization");
- expect(document.body.textContent).not.toContain("Organization settings");
- expect(document.body.textContent).not.toContain("Switch company");
- expect(document.body.textContent).not.toContain("Create new company...");
- expect(document.body.textContent).not.toContain("Company settings");
-
- // Stacks, not companies: both rows carry the slug badge, and the current
- // stack is the checked one.
- expect(document.body.textContent).toContain("Acme Labs");
- expect(document.body.textContent).toContain("acme-labs");
- expect(document.body.textContent).toContain("Strata Systems");
- expect(document.body.textContent).toContain("strata");
- expect(document.body.textContent).not.toContain("Anachronist Wiki");
- expect(document.body.textContent).not.toContain("ANA");
-
- const currentRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
- .find((element) => element.textContent?.includes("Acme Labs"));
- expect(currentRow?.classList.contains("bg-accent")).toBe(false);
-
- // Long slugs must not squeeze the display name out of the row, so the
- // secondary line truncates and keeps the full slug on hover.
- const slugBadge = document.body.querySelector('[title="strata"]');
- expect(slugBadge?.className).toContain("truncate");
-
- // Drag-to-reorder is self-hosted only in v1.
- expect(Array.from(document.body.querySelectorAll("button"))
- .some((element) => element.textContent === "Edit")).toBe(false);
-
- act(() => {
- root.unmount();
- });
- });
-
- it("shows the synced company logo on the trigger while stack rows keep monograms", async () => {
- const { root } = renderMenu({ cloud: true });
- await flushReact();
- await flushReact();
-
- // A Cloud tenant holds exactly one company, and the harness pushes the
- // stack's uploaded workspace icon into that company's branding — so the
- // trigger renders the company logo, not the monogram.
- const trigger = container.querySelector(
- 'button[aria-label="Open Acme Labs organization switcher"]',
- );
- expect(trigger).not.toBeNull();
- expect(
- trigger?.querySelector('[data-logo-url="/api/assets/logo-asset-1/content"]'),
- ).not.toBeNull();
-
- // Other stacks have no hot-linkable icon in the portfolio payload, so
- // their rows keep the deterministic monogram treatment.
- await openMenu("Open Acme Labs organization switcher");
- const strataRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
- .find((element) => element.textContent?.includes("Strata Systems"));
- expect(strataRow).toBeTruthy();
- expect(strataRow?.querySelector("[data-logo-url]")).toBeNull();
-
- act(() => {
- root.unmount();
- });
- });
-
- it("enters another stack through a full top-level navigation", async () => {
- const { root } = renderMenu({ cloud: true });
- await flushReact();
- await flushReact();
- await openMenu("Open Acme Labs organization switcher");
-
- const strataRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
- .find((element) => element.textContent?.includes("Strata Systems"));
- expect(strataRow).toBeTruthy();
-
- act(() => {
- strataRow?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushReact();
-
- expect(mockNavigateTopLevel).toHaveBeenCalledWith(
- "https://cloud.example.test/stacks/strata/enter",
- );
- // No in-app company selection and no client-side route change.
- expect(mockSetSelectedCompanyId).not.toHaveBeenCalled();
- expect(mockNavigate).not.toHaveBeenCalled();
-
- act(() => {
- root.unmount();
- });
- });
-
- it("does not navigate when the current stack is picked again", async () => {
- const { root } = renderMenu({ cloud: true });
- await flushReact();
- await flushReact();
- await openMenu("Open Acme Labs organization switcher");
-
- const currentRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
- .find((element) => element.textContent?.includes("Acme Labs"));
-
- act(() => {
- currentRow?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushReact();
-
- expect(mockNavigateTopLevel).not.toHaveBeenCalled();
-
- act(() => {
- root.unmount();
- });
- });
-
- it("sends organization creation to the cloud app, not the in-app wizard", async () => {
- const { root } = renderMenu({ cloud: true });
- await flushReact();
- await flushReact();
- await openMenu("Open Acme Labs organization switcher");
-
- const createItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
- .find((element) => element.textContent?.includes("Create organization"));
- expect(createItem).toBeTruthy();
-
- act(() => {
- createItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushReact();
-
- expect(mockNavigateTopLevel).toHaveBeenCalledWith("https://cloud.example.test/stacks/new");
- expect(mockOpenOnboarding).not.toHaveBeenCalled();
-
- act(() => {
- root.unmount();
- });
- });
-
- it("falls back to the stack slug and hides creation without a cloud base url", async () => {
- mockCloudApi.listStacks.mockRejectedValue(new Error("portfolio unavailable"));
- const { root } = renderMenu({
- cloud: true,
- health: {
- status: "ok" as const,
- cloud: { ...CLOUD_HEALTH.cloud, cloudBaseUrl: null },
- },
- });
- await flushReact();
- await flushReact();
-
- await openMenu("Open acme-labs organization switcher");
-
- expect(document.body.textContent).toContain("Could not load organizations");
+ await openMenu(`Open Acme Labs ${production ? "company" : "organization"} switcher`);
+ expect(document.body.textContent).toContain("Anachronist Wiki");
+ expect(document.body.textContent).toContain("ANA");
expect(document.body.textContent).not.toContain("Create organization");
-
- act(() => {
- root.unmount();
- });
+ expect(mockOpenOnboarding).not.toHaveBeenCalled();
+ const row = [...document.querySelectorAll('[data-slot="dropdown-menu-item"]')]
+ .find(element => element.textContent?.includes("Anachronist Wiki"));
+ act(() => row?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
+ expect(mockSetSelectedCompanyId).toHaveBeenCalledWith("company-3");
+ expect(mockNavigate).toHaveBeenCalledWith("/ANA/dashboard");
+ expect(mockNavigateTopLevel).not.toHaveBeenCalled();
+ act(() => root.unmount());
});
});
});
diff --git a/ui/src/components/SidebarCompanyMenu.tsx b/ui/src/components/SidebarCompanyMenu.tsx
index 04b285b3b5f..3a9e95f5337 100644
--- a/ui/src/components/SidebarCompanyMenu.tsx
+++ b/ui/src/components/SidebarCompanyMenu.tsx
@@ -23,7 +23,6 @@ import { CSS } from "@dnd-kit/utilities";
import { hidesCompanyPage, type Company } from "@paperclipai/shared";
import { Link, useLocation, useNavigate } from "@/lib/router";
import { authApi } from "@/api/auth";
-import { cloudApi, type CloudStackSummary } from "@/api/cloud";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -38,8 +37,6 @@ import { useCloudInstance } from "@/hooks/useCloudInstance";
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
import { useCompanyOrder } from "@/hooks/useCompanyOrder";
import { useSignOut } from "@/hooks/useSignOut";
-import { navigateTopLevel } from "@/lib/browserNavigation";
-import { cloudStackCreateUrl, cloudStackEnterUrl } from "@/lib/cloudLinks";
import { queryKeys } from "@/lib/queryKeys";
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils";
import { useSidebar } from "../context/SidebarContext";
@@ -70,73 +67,6 @@ function WorkspaceIcon({ company, inPopover = false }: { company: Company; inPop
);
}
-/**
- * Cloud stacks have no hot-linkable icon URL in the portfolio payload, so v1
- * renders the same deterministic monogram treatment cloud's own portfolio page
- * uses — seeded by the display name, never fetched.
- */
-function StackIcon({ displayName }: { displayName: string }) {
- return ;
-}
-
-/**
- * The switcher trigger on a Cloud instance. A Cloud tenant holds exactly one
- * company and the harness pushes the stack's uploaded workspace icon into
- * that company's branding, so the company logo is the stack logo here.
- * The stack rows keep the monogram treatment above.
- */
-function CurrentStackIcon({
- displayName,
- company,
-}: {
- displayName: string;
- company: Company | null;
-}) {
- return (
-
- );
-}
-
-function CloudStackItem({
- stack,
- isSelected,
- onSelect,
-}: {
- stack: CloudStackSummary;
- isSelected: boolean;
- onSelect: (stack: CloudStackSummary) => void;
-}) {
- return (
- onSelect(stack)}
- className={ORGANIZATION_ROW_CLASS}
- >
-
-
-
- {stack.displayName}
-
-
- {stack.stackSlug}
-
-
-
- {isSelected ? : null}
-
-
- );
-}
-
function SortableCompanyItem({
company,
isEditing,
@@ -257,40 +187,16 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
userId: currentUserId,
});
- // In Paperclip Cloud the switcher lists the signed-in user's stacks
- // (organizations) instead of the instance's companies: a cloud instance holds
- // exactly one company, and switching means leaving this tenant host entirely.
- const cloud = useCloudInstance();
- const isCloud = Boolean(cloud);
- // Invites now live on the Members page; hide the shortcut when the hosting
- // operator hides either surface. Until the health response resolves, the
- // hidden set is unknown — keep the shortcut out rather than flash it.
+ // Keep invitation links hidden until the operator policy is known.
const { hidden: hiddenSettings, loaded: hiddenSettingsLoaded } = useHiddenSettings();
const showInvitePeople =
hiddenSettingsLoaded &&
!hidesCompanyPage(hiddenSettings, "company.members") &&
!hidesCompanyPage(hiddenSettings, "company.invites");
- const cloudBaseUrl = cloud?.cloudBaseUrl ?? null;
- const stacksQuery = useQuery({
- queryKey: queryKeys.cloud.stacks,
- queryFn: () => cloudApi.listStacks(),
- enabled: isCloud,
- staleTime: 30_000,
- retry: false,
- });
- const stacks = stacksQuery.data?.stacks ?? [];
- const currentStack = isCloud
- ? stacks.find((stack) => stack.isCurrent)
- ?? stacks.find((stack) => Boolean(cloud?.stackSlug) && stack.stackSlug === cloud?.stackSlug)
- ?? null
- : null;
- const createStackUrl = isCloud ? cloudStackCreateUrl(cloudBaseUrl) : null;
const switcherNoun = "organization";
- // The one name the chrome shows for "where am I": the stack in cloud, the
- // company when self-hosted.
- const currentName = isCloud
- ? currentStack?.displayName ?? cloud?.stackDisplayName ?? cloud?.stackSlug ?? null
- : selectedCompany?.name ?? null;
+ const currentName = selectedCompany?.name ?? null;
+ // Managed hosts forbid local company creation. Their extension owns that action.
+ const companyCreationManaged = Boolean(useCloudInstance());
const signOutMutation = useSignOut({ onSignedOut: closeNavigationChrome });
@@ -321,29 +227,10 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
}
}
- /**
- * Switching stacks is a full top-level navigation, not client routing: the
- * cloud entry-code handoff authenticates the user for the target stack and
- * wakes it if it is asleep before landing on its own tenant host.
- */
- function selectStack(stack: CloudStackSummary) {
- setOpen(false);
- if (isMobile) setSidebarOpen(false);
- if (stack.stackSlug === currentStack?.stackSlug) return;
- const target = cloudStackEnterUrl(cloudBaseUrl, stack.stackSlug);
- if (!target) return;
- navigateTopLevel(target);
- }
-
function addCompany() {
+ if (companyCreationManaged) return;
setOpen(false);
if (isMobile) setSidebarOpen(false);
- // Cloud creates organizations in the cloud app; the in-app company wizard
- // is unreachable there (POST /companies is a 403 floor on managed stacks).
- if (isCloud) {
- if (createStackUrl) navigateTopLevel(createStackUrl);
- return;
- }
// Skip the front-door "how would you like to get started?" choice and land
// directly on "Name your organization" — this entry point is unambiguously
// "create a new company" (PAP-431).
@@ -377,8 +264,7 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
// `min-w-0` on every link of the flex chain (button → label row → label)
// is what lets the name truncate: a flex item's default `min-width:auto`
// floors it at its content width, so without it a long name widens the
- // trigger past the sidebar and pushes the chevron out of bounds. Company
- // names were short in practice; cloud stack names are user-chosen.
+ // trigger past the sidebar and pushes the chevron out of bounds.
className="h-9 min-w-0 flex-1 justify-start gap-2 px-4 text-left hover:bg-sidebar-accent hover:text-sidebar-accent-foreground has-[>svg]:px-4 dark:hover:bg-sidebar-accent dark:hover:text-sidebar-accent-foreground"
aria-label={
currentName
@@ -387,9 +273,7 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
}
>
- {isCloud
- ? currentName ? : null
- : selectedCompany ? : null}
+ {selectedCompany ? : null}
{/* The header has room for ~110px of name beside the collapse
control (~142px on mobile, which hides it) — search moved to
the nav to buy that width. A name that still
@@ -416,95 +300,65 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
Organizations
- {/* Stack order is owned by cloud's own portfolio in v1, so the
- drag-to-reorder affordance stays self-hosted-only. */}
- {isCloud ? null : (
-
- )}
+
- {isCloud ? (
- <>
- {stacks.map((stack) => (
-
+ company.id)}
+ strategy={verticalListSortingStrategy}
+ >
+ {orderedCompanies.map((company) => (
+
))}
- {stacks.length === 0 ? (
-
- {stacksQuery.isLoading
- ? "Loading organizations..."
- : stacksQuery.isError
- ? "Could not load organizations"
- : "No organizations"}
-
- ) : null}
- >
- ) : (
- <>
-
- company.id)}
- strategy={verticalListSortingStrategy}
+
+
+ {orderedCompanies.length === 0 ? (
+ // "No companies" is a claim about the account. After a failed
+ // list request it is one we cannot make, and this menu is the
+ // only place the customer can act on it — say what happened and
+ // offer the way back.
+ companyListUnavailable ? (
+ <>
+ Couldn't load organizations
+ {
+ // Keep the menu open so the result of the retry is visible.
+ event.preventDefault();
+ void retryCompanies();
+ }}
>
- {orderedCompanies.map((company) => (
-
- ))}
-
-
- {orderedCompanies.length === 0 ? (
- // "No companies" is a claim about the account. After a failed
- // list request it is one we cannot make, and this menu is the
- // only place the customer can act on it — say what happened and
- // offer the way back.
- companyListUnavailable ? (
- <>
- Couldn't load organizations
- {
- // Keep the menu open so the result of the retry is visible.
- event.preventDefault();
- void retryCompanies();
- }}
- >
-
- Try again
-
- >
- ) : (
- No organizations
- )
- ) : null}
- >
- )}
+
+ Try again
+
+ >
+ ) : (
+ No organizations
+ )
+ ) : null}
- {/* A cloud instance without a configured cloud origin has nowhere to
- send the user, so the row drops out entirely. */}
- {isCloud && !createStackUrl ? null : (
+ {!companyCreationManaged && (
{
it("resolves stack links against the cloud origin", () => {
- expect(cloudStackEnterUrl("https://app.paperclip.app", "acme")).toBe(
- "https://app.paperclip.app/stacks/acme/enter",
- );
expect(cloudStackCreateUrl("https://app.paperclip.app")).toBe(
"https://app.paperclip.app/stacks/new",
);
@@ -15,18 +12,6 @@ describe("cloudLinks", () => {
expect(cloudPortfolioManageUrl(null)).toBeNull();
});
- it("drops a control-plane path suffix on the configured origin", () => {
- expect(cloudStackEnterUrl("https://cloud.example.test/control-plane", "acme")).toBe(
- "https://cloud.example.test/stacks/acme/enter",
- );
- });
-
- it("escapes slugs so a crafted portfolio entry cannot climb the path", () => {
- expect(cloudStackEnterUrl("https://app.paperclip.app", "../../evil")).toBe(
- "https://app.paperclip.app/stacks/..%2F..%2Fevil/enter",
- );
- });
-
it("opens Cloud People settings on the configured origin with an escaped stack slug", () => {
expect(cloudStackInviteUrl("https://cloud.example.test/control-plane", "team/with?query")).toBe(
"https://cloud.example.test/workspaces/team%2Fwith%3Fquery/settings?section=people",
@@ -36,11 +21,7 @@ describe("cloudLinks", () => {
expect(cloudStackInviteUrl("javascript:alert(1)", "team")).toBeNull();
});
- it("returns null without a usable base or slug", () => {
- expect(cloudStackEnterUrl(null, "acme")).toBeNull();
- expect(cloudStackEnterUrl(" ", "acme")).toBeNull();
- expect(cloudStackEnterUrl("https://app.paperclip.app", " ")).toBeNull();
- expect(cloudStackEnterUrl("not a url", "acme")).toBeNull();
+ it("returns null without a usable base", () => {
expect(cloudStackCreateUrl(undefined)).toBeNull();
});
diff --git a/ui/src/lib/cloudLinks.ts b/ui/src/lib/cloudLinks.ts
index 79a3f35a3be..68d02f1ed1a 100644
--- a/ui/src/lib/cloudLinks.ts
+++ b/ui/src/lib/cloudLinks.ts
@@ -25,19 +25,6 @@ export function cloudAppUrl(cloudBaseUrl: string | null | undefined, path: strin
}
}
-/**
- * Entry-code handoff for another stack: cloud authenticates the user for that
- * stack and wakes it if it is asleep before redirecting to its tenant host.
- */
-export function cloudStackEnterUrl(
- cloudBaseUrl: string | null | undefined,
- stackSlug: string | null | undefined,
-): string | null {
- const slug = stackSlug?.trim();
- if (!slug) return null;
- return cloudAppUrl(cloudBaseUrl, `/stacks/${encodeURIComponent(slug)}/enter`);
-}
-
/** Cloud's own create-a-stack flow, which replaces the in-app company wizard. */
export function cloudStackCreateUrl(cloudBaseUrl: string | null | undefined): string | null {
return cloudAppUrl(cloudBaseUrl, "/stacks/new");