refactor(ui): remove the legacy Cloud organization switcher (#14061)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Its sidebar provides navigation between organizations.
> - The generic organization switcher slot lets installed plugins own
that navigation.
> - Both Core UI shells still contain the old Cloud portfolio menu.
> - Cloud now uses its private Account plugin for this menu.
> - This pull request removes the duplicate Cloud menu and keeps the
built-in company menu.
> - This reduces Cloud-specific code without changing the plugin host
contract.

## Linked Issues or Issue Description

Refs #13832 and #13854. Related #14060 changes the account popup, not
this organization switcher.

**What existing behavior does this improve?**

Organization navigation in both sidebar shells.

**Current behavior**

Core retains Cloud portfolio fetching, stack rows and stack-entry links
behind the plugin replacement slot.

**Proposed behavior**

The installed switcher plugin owns Cloud navigation. Core lists local
companies when no usable replacement exists. The Members-page Cloud
invitation action keeps its existing portfolio API; it is a live caller,
not an old-image fallback.

## What Changed

- Remove Cloud portfolio queries, stack rendering and Cloud
creation/entry branches from both built-in menus.
- Remove the unused stack-entry URL helper.
- Keep company selection, ordering, invitations, logout and plugin error
handling. Hide local company creation on managed hosts, where the server
forbids it.
- Update switcher tests and the navigation contract.

## Verification

- `pnpm -r typecheck` passed, including Rust checks with the installed
Cargo toolchain on PATH.
- `pnpm exec vitest run --project @paperclipai/ui`: 637 files and 6,727
tests passed.
- Focused switcher, plugin host and Cloud link tests: 26 passed after
the managed-host creation guard.
- `pnpm check:token-gates` passed.
- Full `pnpm test:run` was attempted, then stopped after failures in
unchanged server tests. Targeted reproduction found an ancestor
skills-directory collision for Slack and macOS EACCES errors renaming
the company skills cache. Other local failures appeared in email
connector skill setup and a process-turn test. This is not a local
full-suite pass; clean Linux CI covers the complete suite.
- Full `pnpm build`, UI production build and Storybook build passed. All
[latest-head CI
checks](https://github.com/paperclipai/paperclip/actions/runs/36201814444)
passed, including the full Linux test matrix, browser tests, typecheck,
build and release checks. Greptile is 5/5 with no unresolved comments.

## Risks

- A managed host without a usable switcher plugin now gets the ordinary
company menu. It no longer gets the old Cloud portfolio menu, and local
company creation remains unavailable there.
- The Cloud portfolio endpoint remains required by the Members-page
invitation action. This PR does not remove that live endpoint or change
its authorization.
- No database, authentication, plugin protocol or deployment changes.

## Model Used

OpenAI Codex, GPT-6, with reasoning, repository inspection and code
execution. The exact deployment identifier and context-window size are
not exposed by this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-09-25 22:58:52 -07:00
1 parent 4ca404b49a
commit 7f3c06dac4
6 files changed
+148 -644

No files matched your search

+10 -1
View File
@@ -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`
@@ -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 <CompanyPatternIcon companyName={displayName} className={POPOVER_WORKSPACE_ICON_CLASS} />;
}
/**
* 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 (
<CompanyPatternIcon
companyName={displayName}
logoUrl={company?.logoUrl}
className={TRIGGER_WORKSPACE_ICON_CLASS}
/>
);
}
function CloudStackItem({
stack,
isSelected,
onSelect,
}: {
stack: CloudStackSummary;
isSelected: boolean;
onSelect: (stack: CloudStackSummary) => void;
}) {
return (
<DropdownMenuItem
onSelect={() => onSelect(stack)}
className={ORGANIZATION_ROW_CLASS}
>
<StackIcon displayName={stack.displayName} />
<span className="min-w-0 flex-1">
<span
className="block truncate font-medium leading-(--organization-popover-name-line-height)"
title={stack.displayName}
>
{stack.displayName}
</span>
<span
className="block truncate text-(length:--text-nano) leading-(--organization-popover-prefix-line-height) text-muted-foreground"
title={stack.stackSlug}
>
{stack.stackSlug}
</span>
</span>
<span className="flex size-5 shrink-0 items-center justify-center">
{isSelected ? <Check className="size-4 text-foreground" /> : null}
</span>
</DropdownMenuItem>
);
}
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
}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
{isCloud
? currentName ? <CurrentStackIcon displayName={currentName} company={selectedCompany} /> : null
: selectedCompany ? <WorkspaceIcon company={selectedCompany} /> : null}
{selectedCompany ? <WorkspaceIcon company={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}`}
</span>
</span>
{!rail && <ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />}
@@ -420,95 +300,65 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
<DropdownMenuLabel className="p-0 text-(length:--text-compact) font-semibold text-foreground">
Organizations
</DropdownMenuLabel>
{/* Stack order is owned by cloud's own portfolio in v1, so the
drag-to-reorder affordance stays self-hosted-only. */}
{isCloud ? null : (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsEditingOrder((current) => !current);
}}
className="rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{isEditingOrder ? "Done" : "Edit"}
</button>
)}
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsEditingOrder((current) => !current);
}}
className="rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{isEditingOrder ? "Done" : "Edit"}
</button>
</div>
<div className="flex max-h-96 flex-col gap-0.5 overflow-y-auto px-2.5 pb-2 pt-1">
{isCloud ? (
<>
{stacks.map((stack) => (
<CloudStackItem
key={stack.stackSlug}
stack={stack}
isSelected={stack.stackSlug === currentStack?.stackSlug}
onSelect={selectStack}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedCompanies.map((company) => company.id)}
strategy={verticalListSortingStrategy}
>
{orderedCompanies.map((company) => (
<SortableCompanyItem
key={company.id}
company={company}
isEditing={isEditingOrder}
isSelected={company.id === selectedCompany?.id}
onSelect={selectCompany}
/>
))}
{stacks.length === 0 ? (
<DropdownMenuItem disabled>
{stacksQuery.isLoading
? "Loading organizations..."
: stacksQuery.isError
? "Could not load organizations"
: "No organizations"}
</DropdownMenuItem>
) : null}
</>
) : (
<>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedCompanies.map((company) => company.id)}
strategy={verticalListSortingStrategy}
</SortableContext>
</DndContext>
{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 ? (
<>
<DropdownMenuItem disabled>Couldn&apos;t load companies</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
// Keep the menu open so the result of the retry is visible.
event.preventDefault();
void retryCompanies();
}}
>
{orderedCompanies.map((company) => (
<SortableCompanyItem
key={company.id}
company={company}
isEditing={isEditingOrder}
isSelected={company.id === selectedCompany?.id}
onSelect={selectCompany}
/>
))}
</SortableContext>
</DndContext>
{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 ? (
<>
<DropdownMenuItem disabled>Couldn&apos;t load companies</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
// Keep the menu open so the result of the retry is visible.
event.preventDefault();
void retryCompanies();
}}
>
<RefreshCw className="h-4 w-4 mr-2" />
Try again
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem disabled>No companies</DropdownMenuItem>
)
) : null}
</>
)}
<RefreshCw className="h-4 w-4 mr-2" />
Try again
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem disabled>No companies</DropdownMenuItem>
)
) : null}
</div>
<div className="flex flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
{/* A cloud instance without a configured cloud origin has nowhere to
send the user, so the row drops out entirely. */}
{isCloud && !createStackUrl ? null : (
{!companyCreationManaged && (
<DropdownMenuItem
onClick={addCompany}
className={ORGANIZATION_ACTION_CLASS}
+16 -193
View File
@@ -28,19 +28,12 @@ const mockSidebarPreferencesApi = vi.hoisted(() => ({
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(
<QueryClientProvider client={queryClient}>
<SidebarCompanyMenu />
<Menu />
</QueryClientProvider>,
);
});
@@ -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());
});
});
});
+59 -205
View File
@@ -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 <CompanyPatternIcon companyName={displayName} className={POPOVER_WORKSPACE_ICON_CLASS} />;
}
/**
* 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 (
<CompanyPatternIcon
companyName={displayName}
logoUrl={company?.logoUrl}
className={TRIGGER_WORKSPACE_ICON_CLASS}
/>
);
}
function CloudStackItem({
stack,
isSelected,
onSelect,
}: {
stack: CloudStackSummary;
isSelected: boolean;
onSelect: (stack: CloudStackSummary) => void;
}) {
return (
<DropdownMenuItem
onSelect={() => onSelect(stack)}
className={ORGANIZATION_ROW_CLASS}
>
<StackIcon displayName={stack.displayName} />
<span className="min-w-0 flex-1">
<span
className="block truncate font-medium leading-(--organization-popover-name-line-height)"
title={stack.displayName}
>
{stack.displayName}
</span>
<span
className="block truncate text-(length:--text-nano) leading-(--organization-popover-prefix-line-height) text-muted-foreground"
title={stack.stackSlug}
>
{stack.stackSlug}
</span>
</span>
<span className="flex size-5 shrink-0 items-center justify-center">
{isSelected ? <Check className="size-4 text-foreground" /> : null}
</span>
</DropdownMenuItem>
);
}
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
}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
{isCloud
? currentName ? <CurrentStackIcon displayName={currentName} company={selectedCompany} /> : null
: selectedCompany ? <WorkspaceIcon company={selectedCompany} /> : null}
{selectedCompany ? <WorkspaceIcon company={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
<DropdownMenuLabel className="p-0 text-(length:--text-compact) font-semibold text-foreground">
Organizations
</DropdownMenuLabel>
{/* Stack order is owned by cloud's own portfolio in v1, so the
drag-to-reorder affordance stays self-hosted-only. */}
{isCloud ? null : (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsEditingOrder((current) => !current);
}}
className="rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{isEditingOrder ? "Done" : "Edit"}
</button>
)}
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsEditingOrder((current) => !current);
}}
className="rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{isEditingOrder ? "Done" : "Edit"}
</button>
</div>
<div className="flex max-h-96 flex-col gap-0.5 overflow-y-auto px-2.5 pb-2 pt-1">
{isCloud ? (
<>
{stacks.map((stack) => (
<CloudStackItem
key={stack.stackSlug}
stack={stack}
isSelected={stack.stackSlug === currentStack?.stackSlug}
onSelect={selectStack}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedCompanies.map((company) => company.id)}
strategy={verticalListSortingStrategy}
>
{orderedCompanies.map((company) => (
<SortableCompanyItem
key={company.id}
company={company}
isEditing={isEditingOrder}
isSelected={company.id === selectedCompany?.id}
onSelect={selectCompany}
/>
))}
{stacks.length === 0 ? (
<DropdownMenuItem disabled>
{stacksQuery.isLoading
? "Loading organizations..."
: stacksQuery.isError
? "Could not load organizations"
: "No organizations"}
</DropdownMenuItem>
) : null}
</>
) : (
<>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedCompanies.map((company) => company.id)}
strategy={verticalListSortingStrategy}
</SortableContext>
</DndContext>
{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 ? (
<>
<DropdownMenuItem disabled>Couldn&apos;t load organizations</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
// Keep the menu open so the result of the retry is visible.
event.preventDefault();
void retryCompanies();
}}
>
{orderedCompanies.map((company) => (
<SortableCompanyItem
key={company.id}
company={company}
isEditing={isEditingOrder}
isSelected={company.id === selectedCompany?.id}
onSelect={selectCompany}
/>
))}
</SortableContext>
</DndContext>
{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 ? (
<>
<DropdownMenuItem disabled>Couldn&apos;t load organizations</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
// Keep the menu open so the result of the retry is visible.
event.preventDefault();
void retryCompanies();
}}
>
<RefreshCw className="h-4 w-4 mr-2" />
Try again
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem disabled>No organizations</DropdownMenuItem>
)
) : null}
</>
)}
<RefreshCw className="h-4 w-4 mr-2" />
Try again
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem disabled>No organizations</DropdownMenuItem>
)
) : null}
</div>
<div className="flex flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
{/* A cloud instance without a configured cloud origin has nowhere to
send the user, so the row drops out entirely. */}
{isCloud && !createStackUrl ? null : (
{!companyCreationManaged && (
<DropdownMenuItem
onClick={addCompany}
className={ORGANIZATION_ACTION_CLASS}
+2 -21
View File
@@ -1,11 +1,8 @@
import { describe, expect, it } from "vitest";
import { cloudAppUrl, cloudPortfolioManageUrl, cloudStackCreateUrl, cloudStackEnterUrl, cloudStackInviteUrl } from "./cloudLinks";
import { cloudAppUrl, cloudPortfolioManageUrl, cloudStackCreateUrl, cloudStackInviteUrl } from "./cloudLinks";
describe("cloudLinks", () => {
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();
});
-13
View File
@@ -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");