From 7badae69810b3e46386ebfffe01a1648f0c63598 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 22 Sep 2026 16:39:11 -0700 Subject: [PATCH] feat(plugins): add an optional organization switcher slot (#13832) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Plugins can add UI surfaces to the board. > - Organization navigation is still fixed in the host sidebar. > - A distribution needs a supported way to supply its own organization menu. > - This pull request adds one optional React slot with host-owned navigation controls. > - The built-in menu stays available when the optional contribution cannot render. ## Linked Issues or Issue Description **Subsystem affected** Plugin SDK, server capability validation, and sidebar UI. **Problem or motivation** An installed plugin cannot replace the organization switcher without editing the host menu. Existing sidebar and overlay slots do not provide this replacement surface. **Proposed solution** Add an `organizationSwitcher` slot that requires `ui.sidebar.register`. Pass display state, an icon renderer, and navigation/logout callbacks. Keep the built-in menu for absent, ambiguous, missing, failed, or unsupported contributions. **Roadmap alignment** This keeps distribution UI in plugins and adds a small host contract. It does not add an account system or change company authorization. The maintainer requested this extension. Related prior menu changes: #12788, #10917, and #10850. No duplicate replacement-slot PR was found. ## What Changed - Add the slot to shared validation, SDK types, and server capability checks. - Wrap both sidebar menu variants with the optional replacement. - Resolve selection against the current account query before mounting, and reset replacement state on account or company changes. - Add host-specific component props and a fallback to `PluginSlotMount`. - Document the React-only contract and its trust boundary. - Report runtime-supervisor fixture startup details when CI readiness fails. ## Verification - `pnpm -r typecheck` and `pnpm build` passed. - `pnpm check:token-gates` passed. - `pnpm exec vitest run --project @paperclipai/ui`: 6,580 tests passed. - Targeted manifest, replacement, and built-in menu tests: 26 passed, including the incoming-account selection regression. - Installed a local test contribution into an isolated server. CLI inspection reported `ready`. Browser checks covered the loaded production UI, keyboard dismissal, current-organization selection, and an expired remote session. Remote account responses were fixtures. - `pnpm test:run` was attempted. Its first server phase passed 8,323 tests but failed in 36 files due to embedded PostgreSQL startup and filesystem permission errors on this Mac. Later phases did not run. The current Linux CI run is green: 54 checks passed and two were skipped, including build, typecheck, and browser gates. See https://github.com/paperclipai/paperclip/actions/runs/35796722770. - The earlier runtime-supervisor readiness failure did not reproduce locally. The complete affected shard passed locally: 58 files and 843 tests. The six supervisor tests also passed on Node 24.21.0 with CI flags. Added fixture startup diagnostics for the selected Node executable and listener port. The affected Linux shard then passed all 843 tests. The original root cause remains unconfirmed; no production runtime behavior or timeout was changed. ## Risks - Plugin UI remains trusted same-origin code. Display props do not authorize account requests. - A replacement can change navigation behavior. The host retains the built-in menu when discovery or rendering fails and keeps logout/session cleanup host-owned. - No database migration. Existing menus and portfolio behavior remain available. - Local full-suite verification is limited by the environment failures listed above. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository inspection, code execution, and browser testing. The runtime does not expose a more specific model ID or context-window size. ## 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/plugins/PLUGIN_AUTHORING_GUIDE.md | 16 ++++ packages/plugins/sdk/src/ui/index.ts | 1 + packages/plugins/sdk/src/ui/types.ts | 21 +++++ packages/shared/src/constants.ts | 1 + .../local-service-supervisor.test.ts | 2 +- .../plugin-organization-switcher.test.ts | 17 ++++ .../services/plugin-capability-validator.ts | 1 + ui/package.json | 1 + .../PluginOrganizationSwitcher.test.tsx | 89 +++++++++++++++++++ .../components/PluginOrganizationSwitcher.tsx | 64 +++++++++++++ .../SidebarCompanyMenu.production.tsx | 12 ++- ui/src/components/SidebarCompanyMenu.test.tsx | 2 + ui/src/components/SidebarCompanyMenu.tsx | 12 ++- ui/src/plugins/slots.tsx | 15 +++- 14 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 server/src/__tests__/plugin-organization-switcher.test.ts create mode 100644 ui/src/components/PluginOrganizationSwitcher.test.tsx create mode 100644 ui/src/components/PluginOrganizationSwitcher.tsx diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md index 00d6cc01bff..091f90e81cf 100644 --- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -369,6 +369,7 @@ Mount surfaces currently wired in the host include: - `projectSidebarItem` - `globalToolbarButton` - `appShellOverlay` (persistent, signed-in application shell) +- `organizationSwitcher` (one React contribution replacing the organization menu) - `toolbarButton` - `contextMenuItem` - `commentAnnotation` @@ -617,3 +618,18 @@ pnpm build For image-supplied plugins and the persistent shell lifecycle, see [Distribution plugins](DISTRIBUTION-PLUGINS.md). + +### Organization switcher + +Declare one `organizationSwitcher` slot with `ui.sidebar.register`. The host +passes `PluginOrganizationSwitcherProps`: current company display data, collapsed +and open state, navigation/logout callbacks, and an icon renderer. Use the host +logout callback; authenticate remote account requests at their owning service. +`currentCompany` describes the host-local company. A distribution plugin must +resolve its external account/organization label itself; the host does not fetch +that portfolio on the plugin's behalf. +The slot props and `useHostContext()` are display context, not proof of identity. +The host resets plugin state on account/company changes and keeps its built-in +menu when no unique contribution exists, discovery fails, the module is missing, +or rendering throws. The slot is a React-only contract; do not use a custom +element export. This replaces only the menu, not company policy or authorization. diff --git a/packages/plugins/sdk/src/ui/index.ts b/packages/plugins/sdk/src/ui/index.ts index cffdb233dc5..f7d1cee64a8 100644 --- a/packages/plugins/sdk/src/ui/index.ts +++ b/packages/plugins/sdk/src/ui/index.ts @@ -152,6 +152,7 @@ export type { PluginWidgetProps, PluginDetailTabProps, PluginSidebarProps, + PluginOrganizationSwitcherProps, PluginRouteSidebarProps, PluginProjectSidebarItemProps, PluginCommentAnnotationProps, diff --git a/packages/plugins/sdk/src/ui/types.ts b/packages/plugins/sdk/src/ui/types.ts index 10478216838..64a9ce2d061 100644 --- a/packages/plugins/sdk/src/ui/types.ts +++ b/packages/plugins/sdk/src/ui/types.ts @@ -16,6 +16,7 @@ import type { AnchorHTMLAttributes, + ReactNode, MouseEvent as ReactMouseEvent, } from "react"; import type { @@ -270,6 +271,26 @@ export interface PluginDetailTabProps { }; } +/** A single installed contribution replaces the organization menu. The host + * retains its built-in menu while loading, on ambiguity, or on render failure. + * These values/callbacks are presentation context, never authorization. */ +export interface PluginOrganizationSwitcherProps { + organizationSwitcher: { + /** Host-local company branding, not an external account/organization label. + * Plugins resolve external organization identity at their owning service. */ + currentCompany: { name: string; logoUrl: string | null } | null; + collapsed: boolean; + open: boolean; + onOpenChange(open: boolean): void; + /** Close mobile navigation before leaving the current organization. */ + onNavigate(): void; + /** Host-owned logout preserves its session/cache invalidation behavior. */ + onSignOut(): void; + signingOut: boolean; + renderIcon(name: string, logoUrl: string | null, inMenu: boolean): ReactNode; + }; +} + /** * Props passed to a plugin sidebar component. * diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 33d1e61e8ef..1c90a7b7a91 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1483,6 +1483,7 @@ export const PLUGIN_UI_SLOT_TYPES = [ "projectSidebarItem", "globalToolbarButton", "appShellOverlay", + "organizationSwitcher", "toolbarButton", "contextMenuItem", "commentAnnotation", diff --git a/server/src/__tests__/local-service-supervisor.test.ts b/server/src/__tests__/local-service-supervisor.test.ts index 99d49e4c1d8..bd891224793 100644 --- a/server/src/__tests__/local-service-supervisor.test.ts +++ b/server/src/__tests__/local-service-supervisor.test.ts @@ -54,7 +54,7 @@ describe("local service supervision", () => { workspaceRuntime: { services: [{ name: "web", - command: "node -e \"const http=require('node:http'); process.on('SIGTERM',()=>{}); http.createServer((req,res)=>{ process.stdout.write('request '+req.url+'\\\\n',(error)=>{ if (!error) res.end('ok'); }); }).listen(Number(process.env.PORT), '127.0.0.1')\"", + command: "command -v node; node --version; node -e \"const http=require('node:http'); console.log('fixture starting', process.execPath, process.env.PORT); process.on('SIGTERM',()=>{}); http.createServer((req,res)=>{ process.stdout.write('request '+req.url+'\\\\n',(error)=>{ if (!error) res.end('ok'); }); }).listen(Number(process.env.PORT), '127.0.0.1',()=>console.log('fixture listening', process.env.PORT))\"", port: { type: "auto" }, readiness: { type: "http", diff --git a/server/src/__tests__/plugin-organization-switcher.test.ts b/server/src/__tests__/plugin-organization-switcher.test.ts new file mode 100644 index 00000000000..f6f20b27f23 --- /dev/null +++ b/server/src/__tests__/plugin-organization-switcher.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { pluginManifestV1Schema } from "@paperclipai/shared"; +import { pluginCapabilityValidator } from "../services/plugin-capability-validator.js"; + +describe("organization switcher installation", () => { + it("accepts the slot and requires the sidebar capability", () => { + const manifest = pluginManifestV1Schema.parse({ + id: "example.account", apiVersion: 1, version: "1.0.0", displayName: "Account", categories: ["ui"], + description: "Organization navigation", author: "Example", + capabilities: ["ui.sidebar.register"], entrypoints: { worker: "./dist/worker.js", ui: "./dist/ui" }, + ui: { slots: [{ type: "organizationSwitcher", id: "organizations", displayName: "Organizations", exportName: "Organizations" }] }, + }); + const validator = pluginCapabilityValidator(); + expect(validator.validateManifestCapabilities(manifest).allowed).toBe(true); + expect(validator.validateManifestCapabilities({ ...manifest, capabilities: [] })).toMatchObject({ allowed: false, missing: ["ui.sidebar.register"] }); + }); +}); diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index eb0b83d3f94..e015a9a8b57 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -164,6 +164,7 @@ const UI_SLOT_CAPABILITIES: Record = { dashboardWidget: "ui.dashboardWidget.register", globalToolbarButton: "ui.action.register", appShellOverlay: "ui.action.register", + organizationSwitcher: "ui.sidebar.register", toolbarButton: "ui.action.register", contextMenuItem: "ui.action.register", commentAnnotation: "ui.commentAnnotation.register", diff --git a/ui/package.json b/ui/package.json index 7104d63d8fa..64b7a759e9f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -74,6 +74,7 @@ "yjs": "13.6.32" }, "devDependencies": { + "@paperclipai/plugin-sdk": "workspace:*", "@sentry/browser": "10.71.0", "@storybook/addon-a11y": "10.5.10", "@storybook/addon-docs": "10.5.10", diff --git a/ui/src/components/PluginOrganizationSwitcher.test.tsx b/ui/src/components/PluginOrganizationSwitcher.test.tsx new file mode 100644 index 00000000000..866b984b020 --- /dev/null +++ b/ui/src/components/PluginOrganizationSwitcher.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import { useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginOrganizationSwitcherProps } from "@paperclipai/plugin-sdk/ui"; +import { PluginOrganizationSwitcher } from "./PluginOrganizationSwitcher"; +import { registerPluginReactComponent, registerPluginWebComponent, type ResolvedPluginSlot } from "@/plugins/slots"; + +const state = vi.hoisted(() => ({ userId: "alice", companyId: "company-a", settled: true, companyListReady: true, companyIds: ["company-a", "company-b"], slots: [] as ResolvedPluginSlot[], errorMessage: null as string | null, mobile: false, collapsed: false, close: vi.fn(), signOut: vi.fn(), props: null as PluginOrganizationSwitcherProps | null })); +vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => state, useCompanyListQuery: () => ({ isSuccess: state.companyListReady, data: { unauthorized: false, companies: state.companyIds.map(id => ({ id, name: "Acme", issuePrefix: "ACME", logoUrl: "/logo" })) } }) })); +vi.mock("@/api/auth", () => ({ authApi: { getSession: async () => ({ user: { id: state.userId } }) } })); +vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.companyId, selectedCompany: { name: "Acme", issuePrefix: "ACME", logoUrl: "/logo" } }) })); +vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: state.mobile, setSidebarOpen: state.close, collapsed: state.collapsed, peeking: false }) })); +vi.mock("@/hooks/useSignOut", () => ({ useSignOut: () => ({ mutate: state.signOut, isPending: false }) })); +vi.mock("./CompanyPatternIcon", () => ({ CompanyPatternIcon: () => icon })); +vi.mock("@/plugins/slots", async (importOriginal) => ({ ...await importOriginal(), usePluginSlots: () => state })); +const slot: ResolvedPluginSlot = { type: "organizationSwitcher", id: "switcher", displayName: "Organizations", exportName: "Switcher", pluginId: "plugin-1", pluginKey: "fixture.switcher", pluginDisplayName: "Fixture", pluginVersion: "1" }; +let root: Root | undefined; +let container: HTMLDivElement; +const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +function render() { + if (!root) { container = document.createElement("div"); document.body.append(container); root = createRoot(container); } + flushSync(() => root!.render()); +} +afterEach(() => { + if (root) flushSync(() => root!.unmount()); root = undefined; container?.remove(); client.clear(); vi.restoreAllMocks(); + Object.assign(state, { userId: "alice", companyId: "company-a", settled: true, companyListReady: true, companyIds: ["company-a", "company-b"], slots: [], errorMessage: null, mobile: false, collapsed: false, props: null }); + state.close.mockClear(); state.signOut.mockClear(); +}); +function register() { + registerPluginReactComponent(slot.pluginKey, slot.exportName, (props) => { + const host = (props as unknown as PluginOrganizationSwitcherProps).organizationSwitcher; + state.props = { organizationSwitcher: host }; + const [selected, setSelected] = useState(false); + return ; + }); + state.slots = [slot]; +} +describe("organization navigation replacement", () => { + it("keeps built-in navigation for absent, ambiguous, failed or unsettled discovery", () => { + render(); expect(container.textContent).toBe("Built-in organizations"); + register(); state.slots = [slot, { ...slot, id: "other" }]; render(); expect(container.textContent).toBe("Built-in organizations"); + state.slots = [slot]; state.errorMessage = "offline"; render(); expect(container.textContent).toBe("Built-in organizations"); + state.errorMessage = null; state.settled = false; render(); expect(container.textContent).toBe("Built-in organizations"); + }); + it("falls back when a declared module is missing or rendering fails", () => { + state.slots = [{ ...slot, exportName: "Missing" }]; render(); expect(container.textContent).toBe("Built-in organizations"); + vi.spyOn(console, "error").mockImplementation(() => {}); + registerPluginReactComponent(slot.pluginKey, "Broken", () => { throw new Error("render failed"); }); + state.slots = [{ ...slot, exportName: "Broken" }]; render(); expect(container.textContent).toBe("Built-in organizations"); + }); + it("does not mount a new account with the previous account's company selection", () => { + register(); render(); + state.userId = "bob"; + state.companyListReady = false; + state.props = null; + render(); + expect(container.textContent).toBe("Built-in organizations"); + expect(state.props).toBeNull(); + state.companyListReady = true; + state.companyIds = ["company-b"]; + render(); + expect(container.textContent).toBe("Built-in organizations"); + expect(state.props).toBeNull(); + state.companyId = "company-b"; + render(); + expect(container.textContent).toBe("Private organizations"); + }); + it("keeps required navigation when the replacement cannot accept React callbacks", () => { + registerPluginWebComponent(slot.pluginKey, "WebSwitcher", "fixture-switcher"); + state.slots = [{ ...slot, exportName: "WebSwitcher" }]; + render(); + expect(container.textContent).toBe("Built-in organizations"); + }); + it("renders one replacement, keeps host controls, and clears state across identity changes", () => { + register(); state.mobile = true; state.collapsed = true; render(); + expect(container.textContent).toBe("Private organizations"); + const host = state.props!.organizationSwitcher; + expect(host.currentCompany).toEqual({ name: "Acme", logoUrl: "/logo" }); expect(host.collapsed).toBe(true); + flushSync(() => host.onOpenChange(true)); expect(state.props!.organizationSwitcher.open).toBe(true); + flushSync(() => host.onNavigate()); expect(state.close).toHaveBeenCalledWith(false); expect(state.props!.organizationSwitcher.open).toBe(false); + host.onSignOut(); expect(state.signOut).toHaveBeenCalledOnce(); + flushSync(() => container.querySelector("button")!.click()); render(); expect(container.textContent).toBe("selected"); + state.userId = "bob"; render(); expect(container.textContent).toBe("Private organizations"); + flushSync(() => container.querySelector("button")!.click()); state.companyId = "company-b"; render(); expect(container.textContent).toBe("Private organizations"); + }); +}); diff --git a/ui/src/components/PluginOrganizationSwitcher.tsx b/ui/src/components/PluginOrganizationSwitcher.tsx new file mode 100644 index 00000000000..19028a0d5ab --- /dev/null +++ b/ui/src/components/PluginOrganizationSwitcher.tsx @@ -0,0 +1,64 @@ +import { useState, type ReactNode } from "react"; +import type { PluginOrganizationSwitcherProps } from "@paperclipai/plugin-sdk/ui"; +import { useAccountIdentity, useCompanyListQuery } from "@/api/companies-query"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { useSignOut } from "@/hooks/useSignOut"; +import { PluginSlotMount, usePluginSlots } from "@/plugins/slots"; +import { CompanyPatternIcon } from "./CompanyPatternIcon"; + +/** Optional replacement; the built-in menu stays usable throughout rollout. */ +export function PluginOrganizationSwitcher({ children, open: controlledOpen, onOpenChange }: { + children: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const { userId, settled } = useAccountIdentity(); + const { selectedCompanyId } = useCompany(); + const companyList = useCompanyListQuery(); + // Selection is component state and can outlive its account until an effect + // clears it. Resolve it against this account's query before mounting plugins. + const selectedCompany = companyList.data?.companies.find(company => company.id === selectedCompanyId) ?? null; + const contextSettled = settled && companyList.isSuccess && !companyList.data?.unauthorized + && (selectedCompanyId === null || selectedCompany !== null); + return ( + + {children} + + ); +} + +function OrganizationSwitcher({ children, settled, companyId, company, open: controlledOpen, onOpenChange }: { + children: ReactNode; settled: boolean; companyId: string | null; + company: { name: string; issuePrefix: string; logoUrl?: string | null } | null; + open?: boolean; onOpenChange?: (open: boolean) => void; +}) { + const [internalOpen, setInternalOpen] = useState(false); + const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); + const open = controlledOpen ?? internalOpen; + const setOpen = onOpenChange ?? setInternalOpen; + function closeNavigation() { + setOpen(false); + if (isMobile) setSidebarOpen(false); + } + const signOut = useSignOut({ onSignedOut: closeNavigation }); + const { slots, errorMessage } = usePluginSlots({ slotTypes: ["organizationSwitcher"], companyId, enabled: settled }); + // Never choose an arbitrary winner for a replacement surface. + if (!settled || errorMessage || slots.length !== 1) return children; + const props: PluginOrganizationSwitcherProps = { + organizationSwitcher: { + currentCompany: company ? { name: company.name, logoUrl: company.logoUrl ?? null } : null, + collapsed: collapsed && !peeking, open, onOpenChange: setOpen, + onNavigate: closeNavigation, onSignOut: () => signOut.mutate(), signingOut: signOut.isPending, + renderIcon: (name, logoUrl, inMenu) => , + }, + }; + return ; +} diff --git a/ui/src/components/SidebarCompanyMenu.production.tsx b/ui/src/components/SidebarCompanyMenu.production.tsx index af118f53280..20d26072389 100644 --- a/ui/src/components/SidebarCompanyMenu.production.tsx +++ b/ui/src/components/SidebarCompanyMenu.production.tsx @@ -45,6 +45,8 @@ import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils"; import { useSidebar } from "../context/SidebarContext"; import { CompanyPatternIcon } from "./CompanyPatternIcon"; +import { PluginOrganizationSwitcher } from "./PluginOrganizationSwitcher"; + interface SidebarCompanyMenuProps { open?: boolean; onOpenChange?: (open: boolean) => void; @@ -212,7 +214,15 @@ function SortableCompanyItem({ ); } -export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) { +export function SidebarCompanyMenu(props: SidebarCompanyMenuProps = {}) { + return ( + + + + ); +} + +function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps) { const [internalOpen, setInternalOpen] = useState(false); const [isEditingOrder, setIsEditingOrder] = useState(false); const { companies, selectedCompany, setSelectedCompanyId, companyListUnavailable, retryCompanies } = diff --git a/ui/src/components/SidebarCompanyMenu.test.tsx b/ui/src/components/SidebarCompanyMenu.test.tsx index 4c07dfb19f2..8280988fdda 100644 --- a/ui/src/components/SidebarCompanyMenu.test.tsx +++ b/ui/src/components/SidebarCompanyMenu.test.tsx @@ -9,6 +9,8 @@ import { queryKeys } from "@/lib/queryKeys"; import { SidebarCompanyMenu } from "./SidebarCompanyMenu"; import { SidebarCompanyMenu as SidebarCompanyMenuProduction } from "./SidebarCompanyMenu.production"; +vi.mock("./PluginOrganizationSwitcher", () => ({ PluginOrganizationSwitcher: ({ children }: { children: ReactNode }) => children })); + const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn(), signInEmail: vi.fn(), diff --git a/ui/src/components/SidebarCompanyMenu.tsx b/ui/src/components/SidebarCompanyMenu.tsx index 0734bb057d0..04b285b3b5f 100644 --- a/ui/src/components/SidebarCompanyMenu.tsx +++ b/ui/src/components/SidebarCompanyMenu.tsx @@ -45,6 +45,8 @@ import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils"; import { useSidebar } from "../context/SidebarContext"; import { CompanyPatternIcon } from "./CompanyPatternIcon"; +import { PluginOrganizationSwitcher } from "./PluginOrganizationSwitcher"; + interface SidebarCompanyMenuProps { open?: boolean; onOpenChange?: (open: boolean) => void; @@ -212,7 +214,15 @@ function SortableCompanyItem({ ); } -export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) { +export function SidebarCompanyMenu(props: SidebarCompanyMenuProps = {}) { + return ( + + + + ); +} + +function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps) { const [internalOpen, setInternalOpen] = useState(false); const [isEditingOrder, setIsEditingOrder] = useState(false); const { companies, selectedCompany, setSelectedCompanyId, companyListUnavailable, retryCompanies } = diff --git a/ui/src/plugins/slots.tsx b/ui/src/plugins/slots.tsx index f5bf01866d2..042acaf0b47 100644 --- a/ui/src/plugins/slots.tsx +++ b/ui/src/plugins/slots.tsx @@ -701,6 +701,7 @@ export function usePluginSlots(filters: SlotFilters): UsePluginSlotsResult { type PluginSlotErrorBoundaryProps = { slot: ResolvedPluginSlot; + fallback?: ReactNode; className?: string; children: ReactNode; }; @@ -728,6 +729,7 @@ class PluginSlotErrorBoundary extends Component {this.props.slot.pluginDisplayName}: failed to render @@ -770,6 +772,10 @@ type PluginSlotMountProps = { context: PluginSlotContext; className?: string; missingBehavior?: "hidden" | "placeholder"; + /** Host-specific props; slot/context cannot be overridden. */ + componentProps?: Record; + /** Preserve required host navigation if an optional component is unavailable. */ + fallback?: ReactNode; }; /** @@ -829,6 +835,8 @@ export function PluginSlotMount({ context, className, missingBehavior = "hidden", + componentProps, + fallback, }: PluginSlotMountProps) { usePluginRegistrySubscription(); const [, forceRerender] = useState(0); @@ -852,6 +860,7 @@ export function PluginSlotMount({ }, [component, slot.pluginId]); if (!component) { + if (fallback !== undefined) return fallback; if (missingBehavior === "hidden") return null; return (
@@ -861,9 +870,9 @@ export function PluginSlotMount({ } if (component.kind === "react") { - const node = createElement(component.component, { slot, context }); + const node = createElement(component.component, { ...componentProps, slot, context }); return ( - + {className ?
{node}
: node}
@@ -871,6 +880,8 @@ export function PluginSlotMount({ ); } + if (componentProps && fallback !== undefined) return fallback; + return (