From 446577d174485a3724f9eca03307f4356036afa7 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 3 Sep 2026 17:39:56 -0700 Subject: [PATCH] fix(ui): honor PAPERCLIP_HIDDEN_SETTINGS in the production switcher menu (#12788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Operators control which settings surfaces appear via the `PAPERCLIP_HIDDEN_SETTINGS` env var (keys like `company.invites`, `company.members`) > - The sidebar organization switcher has an "Invite people" shortcut that points at the company Invites surface > - The streamlined switcher menu already hides that shortcut when the Invites/Members surface is hidden, but the production-shell menu (rendered when the streamlined UI is disabled) renders the invite row unconditionally > - So an operator that hides the Invites surface still sees the shortcut in the production shell — the hide is not fully honored > - This pull request gates the production menu's invite shortcut on the same hide keys, so `PAPERCLIP_HIDDEN_SETTINGS` controls it in both shells > - The benefit is one consistent, per-deployment knob: a hoster that wants the shortcut gone (e.g. Paperclip Cloud, whose managed stacks set `company.invites`) drops it by setting the env var, and every other hoster keeps it by leaving the key unset ## Linked Issues or Issue Description No existing issue. Description follows the enhancement template: **What existing behavior does this improve?** `PAPERCLIP_HIDDEN_SETTINGS` coverage for the organization switcher's "Invite people" shortcut in the production shell. **Subsystem affected** UI — `ui/src/components/SidebarCompanyMenu.production.tsx`. **Current behavior** The streamlined switcher menu hides the "Invite people" shortcut when `company.invites` or `company.members` is hidden. The production-shell menu renders the invite row unconditionally, so the hide keys have no effect there. **Proposed behavior** The production menu computes `showInvitePeople` from the same hide keys and gates the invite row on it. With no hidden settings (the default) the shortcut still shows; hiding either surface removes it in both shells. **Reason and benefit** This is the per-deployment knob operators already use for the Invites surface. Making the production shell honor it gives one consistent mechanism: Paperclip Cloud drops the shortcut on its managed stacks (which set `company.invites`, because the managed invite accept flow is being overhauled), while other hosters keep it by leaving the key unset — no cloud-specific branching in the app. **Breaking changes** None. Default behavior (no hidden settings) is unchanged; this only makes an existing env var take effect where it previously did not. ## What Changed - `SidebarCompanyMenu.production.tsx` imports `useHiddenSettings` + `hidesCompanyPage`, computes `showInvitePeople` exactly as the streamlined menu does, and renders the invite row only when it is true. - Tests: the production shell shows the shortcut by default and hides it when `company.invites` is hidden. - The streamlined menu is unchanged (it already honored the keys). ## Verification - `cd ui && npx vitest run src/components/SidebarCompanyMenu.test.tsx` — 20 tests pass. - `cd ui && npx tsc -p tsconfig.json --noEmit` — clean. - Manual: with `PAPERCLIP_HIDDEN_SETTINGS=company.invites`, the switcher's "Invite people" row is absent in both the streamlined and production shells; with the key unset it is present in both. ## Risks Low risk. UI-only visibility change; default (no hidden settings) is unchanged, and it only extends an existing, documented env var to a shell that was missing it. ## Model Used Claude Fable 5 (`claude-fable-5`, Anthropic), extended thinking, agentic tool use via Claude Code. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .../SidebarCompanyMenu.production.tsx | 51 ++++++++++++------- ui/src/components/SidebarCompanyMenu.test.tsx | 51 +++++++++++++++++++ 2 files changed, 84 insertions(+), 18 deletions(-) diff --git a/ui/src/components/SidebarCompanyMenu.production.tsx b/ui/src/components/SidebarCompanyMenu.production.tsx index 8d2a50a2e3..476fcf6e87 100644 --- a/ui/src/components/SidebarCompanyMenu.production.tsx +++ b/ui/src/components/SidebarCompanyMenu.production.tsx @@ -20,7 +20,7 @@ import { } from "@dnd-kit/core"; import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import type { Company } from "@paperclipai/shared"; +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"; @@ -36,6 +36,7 @@ import { import { useCompany } from "@/context/CompanyContext"; import { useDialogActions } from "@/context/DialogContext"; 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"; @@ -234,6 +235,18 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb // 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. + 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, @@ -477,23 +490,25 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb )} - - { - if (isEditingOrder) { - event.preventDefault(); - return; - } - closeNavigationChrome(); - }} - > - - - {currentName ? `Invite people to ${currentName}` : "Invite people"} - - - + {showInvitePeople ? ( + + { + if (isEditingOrder) { + event.preventDefault(); + return; + } + closeNavigationChrome(); + }} + > + + + {currentName ? `Invite people to ${currentName}` : "Invite people"} + + + + ) : null} {session?.session ? ( <> diff --git a/ui/src/components/SidebarCompanyMenu.test.tsx b/ui/src/components/SidebarCompanyMenu.test.tsx index 133cfea4ab..5c6cd697ef 100644 --- a/ui/src/components/SidebarCompanyMenu.test.tsx +++ b/ui/src/components/SidebarCompanyMenu.test.tsx @@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { queryKeys } from "@/lib/queryKeys"; import { SidebarCompanyMenu } from "./SidebarCompanyMenu"; +import { SidebarCompanyMenu as SidebarCompanyMenuProduction } from "./SidebarCompanyMenu.production"; const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn(), @@ -370,6 +371,56 @@ describe("SidebarCompanyMenu", () => { }); }); + it("shows the production-shell invite shortcut when no surface is hidden", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(queryKeys.health, { status: "ok" }); + const root = createRoot(container); + act(() => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + await openMenu("Open Acme Labs company switcher"); + + expect(document.body.textContent).toContain("Invite people to Acme Labs"); + + act(() => { + root.unmount(); + }); + }); + + it("hides the production-shell invite shortcut when the operator hides the invites surface", async () => { + // The production shell (streamlined UI disabled) must honor + // PAPERCLIP_HIDDEN_SETTINGS like the streamlined menu — this is the knob + // Paperclip Cloud uses to drop the shortcut on its managed stacks. + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(queryKeys.health, { status: "ok", hiddenSettings: ["company.invites"] }); + const root = createRoot(container); + act(() => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + await openMenu("Open Acme Labs company switcher"); + + expect(document.body.textContent).toContain("Switch"); + expect(document.body.textContent).not.toContain("Invite people"); + + act(() => { + root.unmount(); + }); + }); + it("keeps the invite shortcut out of the menu until hidden settings resolve", async () => { // No health data in the cache: the hidden-settings set is unknown, so the // shortcut must not flash in and then disappear once the response lands.