From 7f3c06dac4604dddcf870085f1a623c102261358 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 25 Sep 2026 22:58:52 -0700 Subject: [PATCH] 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 --- doc/SPEC-implementation.md | 11 +- .../SidebarCompanyMenu.production.tsx | 272 ++++-------------- ui/src/components/SidebarCompanyMenu.test.tsx | 209 ++------------ ui/src/components/SidebarCompanyMenu.tsx | 264 ++++------------- ui/src/lib/cloudLinks.test.ts | 23 +- ui/src/lib/cloudLinks.ts | 13 - 6 files changed, 148 insertions(+), 644 deletions(-) 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");