mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 01:25:26 +02:00
## 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>
413 lines
16 KiB
TypeScript
413 lines
16 KiB
TypeScript
import { useCallback, useMemo, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
Check,
|
|
ChevronsUpDown,
|
|
GripVertical,
|
|
LogOut,
|
|
Plus,
|
|
RefreshCw,
|
|
UserPlus,
|
|
} from "lucide-react";
|
|
import {
|
|
DndContext,
|
|
MouseSensor,
|
|
TouchSensor,
|
|
closestCenter,
|
|
type DragEndEvent,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
|
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 { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
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 { queryKeys } from "@/lib/queryKeys";
|
|
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;
|
|
}
|
|
|
|
const TRIGGER_WORKSPACE_ICON_CLASS = "size-5 shrink-0 rounded-md text-(length:--text-micro)";
|
|
const POPOVER_WORKSPACE_ICON_CLASS =
|
|
"size-(--organization-popover-avatar-size) shrink-0 rounded-lg text-(length:--text-micro)";
|
|
const ORGANIZATION_ROW_CLASS =
|
|
"h-(--organization-popover-company-row-height) min-w-0 gap-(--organization-popover-row-gap) rounded-lg px-2.5 py-0 text-(length:--text-compact) focus:bg-accent/50 focus:text-foreground";
|
|
const ORGANIZATION_ACTION_CLASS =
|
|
"h-(--organization-popover-action-row-height) gap-(--organization-popover-row-gap) rounded-lg px-2.5 py-0 text-(length:--text-compact) font-medium leading-(--organization-popover-action-line-height) text-foreground focus:bg-accent/50 focus:text-foreground";
|
|
|
|
function WorkspaceIcon({ company, inPopover = false }: { company: Company; inPopover?: boolean }) {
|
|
return (
|
|
<CompanyPatternIcon
|
|
companyName={company.name}
|
|
logoUrl={company.logoUrl}
|
|
className={inPopover ? POPOVER_WORKSPACE_ICON_CLASS : TRIGGER_WORKSPACE_ICON_CLASS}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SortableCompanyItem({
|
|
company,
|
|
isEditing,
|
|
isSelected,
|
|
onSelect,
|
|
}: {
|
|
company: Company;
|
|
isEditing: boolean;
|
|
isSelected: boolean;
|
|
onSelect: (company: Company) => void;
|
|
}) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setActivatorNodeRef,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: company.id, disabled: !isEditing });
|
|
|
|
return (
|
|
<DropdownMenuItem
|
|
ref={setNodeRef}
|
|
style={{
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
zIndex: isDragging ? 10 : undefined,
|
|
}}
|
|
onSelect={(event) => {
|
|
if (isEditing) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
onSelect(company);
|
|
}}
|
|
className={cn(
|
|
ORGANIZATION_ROW_CLASS,
|
|
isEditing && "cursor-grab",
|
|
isDragging && "opacity-80",
|
|
)}
|
|
>
|
|
<WorkspaceIcon company={company} inPopover />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate font-medium leading-(--organization-popover-name-line-height)">
|
|
{company.name}
|
|
</span>
|
|
{isEditing ? null : (
|
|
<span className="block truncate text-(length:--text-nano) leading-(--organization-popover-prefix-line-height) text-muted-foreground">
|
|
{company.issuePrefix}
|
|
</span>
|
|
)}
|
|
</span>
|
|
{isEditing ? (
|
|
<button
|
|
type="button"
|
|
ref={setActivatorNodeRef}
|
|
aria-label={`Reorder ${company.name}`}
|
|
className="inline-flex size-8 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-(length:--rad-2) focus-visible:ring-ring"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}}
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
<GripVertical className="size-4" aria-hidden="true" />
|
|
</button>
|
|
) : (
|
|
<span className="flex size-5 shrink-0 items-center justify-center">
|
|
{isSelected ? <Check className="size-4 text-foreground" /> : null}
|
|
</span>
|
|
)}
|
|
</DropdownMenuItem>
|
|
);
|
|
}
|
|
|
|
export function SidebarCompanyMenu(props: SidebarCompanyMenuProps = {}) {
|
|
return (
|
|
<PluginOrganizationSwitcher {...props}>
|
|
<BuiltinCompanyMenu {...props} />
|
|
</PluginOrganizationSwitcher>
|
|
);
|
|
}
|
|
|
|
function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps) {
|
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
const [isEditingOrder, setIsEditingOrder] = useState(false);
|
|
const { companies, selectedCompany, setSelectedCompanyId, companyListUnavailable, retryCompanies } =
|
|
useCompany();
|
|
const { openOnboarding } = useDialogActions();
|
|
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
|
const rail = collapsed && !peeking;
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const open = controlledOpen ?? internalOpen;
|
|
const setOpen = onOpenChange ?? setInternalOpen;
|
|
const sensors = useSensors(
|
|
useSensor(MouseSensor, {
|
|
activationConstraint: { distance: 8 },
|
|
}),
|
|
useSensor(TouchSensor, {
|
|
activationConstraint: { delay: 180, tolerance: 6 },
|
|
}),
|
|
);
|
|
const sidebarCompanies = useMemo(
|
|
() => companies.filter((company) => company.status !== "archived"),
|
|
[companies],
|
|
);
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
retry: false,
|
|
});
|
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
|
const { orderedCompanies, persistOrder } = useCompanyOrder({
|
|
companies: sidebarCompanies,
|
|
userId: currentUserId,
|
|
});
|
|
|
|
// 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 switcherNoun = "organization";
|
|
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 });
|
|
|
|
function handleOpenChange(nextOpen: boolean) {
|
|
if (!nextOpen) setIsEditingOrder(false);
|
|
setOpen(nextOpen);
|
|
}
|
|
|
|
function closeNavigationChrome() {
|
|
setOpen(false);
|
|
setIsEditingOrder(false);
|
|
if (isMobile) setSidebarOpen(false);
|
|
}
|
|
|
|
function selectCompany(company: Company) {
|
|
const pathPrefix = location.pathname.split("/")[1]?.toUpperCase();
|
|
const isCompanyRoute = sidebarCompanies.some((sidebarCompany) => (
|
|
sidebarCompany.issuePrefix.toUpperCase() === pathPrefix
|
|
));
|
|
const shouldLeaveCurrentRoute = company.id !== selectedCompany?.id
|
|
&& (location.pathname.startsWith("/instance/") || isCompanyRoute);
|
|
|
|
setSelectedCompanyId(company.id);
|
|
setOpen(false);
|
|
if (isMobile) setSidebarOpen(false);
|
|
if (shouldLeaveCurrentRoute) {
|
|
navigate(`/${company.issuePrefix}/dashboard`);
|
|
}
|
|
}
|
|
|
|
function addCompany() {
|
|
if (companyCreationManaged) return;
|
|
setOpen(false);
|
|
if (isMobile) setSidebarOpen(false);
|
|
// 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).
|
|
openOnboarding({ initialStep: 1 });
|
|
}
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
|
|
const ids = orderedCompanies.map((company) => company.id);
|
|
const oldIndex = ids.indexOf(active.id as string);
|
|
const newIndex = ids.indexOf(over.id as string);
|
|
if (oldIndex === -1 || newIndex === -1) return;
|
|
|
|
persistOrder(arrayMove(ids, oldIndex, newIndex));
|
|
},
|
|
[orderedCompanies, persistOrder],
|
|
);
|
|
|
|
return (
|
|
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
// The nav icon column sits at nav px-3 + item mx-2 + item px-2.
|
|
// Match that inset with wrapper px-3 + trigger px-4. Override the
|
|
// Button's direct-SVG padding too so the expanded chevron cannot pull
|
|
// the avatar four pixels left of the nav icons.
|
|
// `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.
|
|
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
|
|
? `Open ${currentName} ${switcherNoun} switcher`
|
|
: `Open ${switcherNoun} switcher`
|
|
}
|
|
>
|
|
<span className="flex min-w-0 flex-1 items-center gap-2">
|
|
{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
|
|
truncates stays hover-recoverable via title. */}
|
|
<span
|
|
className={cn(
|
|
"min-w-0 truncate text-sm font-bold text-foreground",
|
|
rail && SIDEBAR_RAIL_HIDDEN_LABEL,
|
|
)}
|
|
title={currentName ?? undefined}
|
|
>
|
|
{currentName ?? `Select ${switcherNoun}`}
|
|
</span>
|
|
</span>
|
|
{!rail && <ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
sideOffset={8}
|
|
className="ml-2 w-(--organization-popover-width) max-w-(--sz-calc-24) overflow-hidden rounded-xl border-border bg-popover p-0 shadow-(--shadow-profile-popover)"
|
|
>
|
|
<div className="flex h-(--organization-popover-header-height) items-center justify-between gap-2 px-3.5">
|
|
<DropdownMenuLabel className="p-0 text-(length:--text-compact) font-semibold text-foreground">
|
|
Organizations
|
|
</DropdownMenuLabel>
|
|
<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">
|
|
<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}
|
|
/>
|
|
))}
|
|
</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'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}
|
|
</div>
|
|
<div className="flex flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
|
|
{!companyCreationManaged && (
|
|
<DropdownMenuItem
|
|
onClick={addCompany}
|
|
className={ORGANIZATION_ACTION_CLASS}
|
|
disabled={isEditingOrder}
|
|
>
|
|
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
|
<Plus className="size-4" />
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate">Create organization</span>
|
|
</DropdownMenuItem>
|
|
)}
|
|
{showInvitePeople ? (
|
|
<DropdownMenuItem asChild disabled={isEditingOrder} className={ORGANIZATION_ACTION_CLASS}>
|
|
<Link
|
|
to="/company/settings/members?tab=invites"
|
|
onClick={(event) => {
|
|
if (isEditingOrder) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
closeNavigationChrome();
|
|
}}
|
|
>
|
|
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
|
<UserPlus className="size-4" />
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate">
|
|
{currentName ? `Invite people to ${currentName}` : "Invite people"}
|
|
</span>
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
{session?.session ? (
|
|
<DropdownMenuItem
|
|
className={ORGANIZATION_ACTION_CLASS}
|
|
onClick={() => signOutMutation.mutate()}
|
|
disabled={isEditingOrder || signOutMutation.isPending}
|
|
>
|
|
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
|
<LogOut className="size-4" />
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate">
|
|
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|