mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Every company-scoped screen reads the active company from `CompanyProvider`, which picks one from the `["companies"]` list and remembers it in localStorage > - That cache entry is shared app-wide and carries no account identity, so it survives a change of account in the tab > - The provider therefore auto-selects from whatever list is cached, which can belong to the account that just went away > - This pull request makes the provider watch the account and refuse to derive a selection from a list fetched for a different one > - The benefit is that the app stops pointing at a company the signed-in account may not be able to see ## Linked Issues or Issue Description No public issue exists. Refs #11380, #11382, #11417. The problem follows. **What happened?** `CompanyProvider` auto-selects a company from the shared `["companies"]` cache entry and writes that id to `localStorage`. Nothing ties that entry to an account. When the account changes in the tab, the previous account's list is still served, so the provider can select — and persist — a company belonging to the account that just went away. Company-scoped screens then render against a company the current account may not be able to see. Signing in through `Auth.tsx` invalidates the entry, so the in-app sign-in path is covered. Two paths are not: a session that lapses server-side, and a second account signing in on another tab. The sign-out sweep in #11380 does not cover them either, because neither presses the sign-out button. **Expected behavior** The company selection is derived only from a company list fetched for the account that is signed in now. **Steps to reproduce** 1. On a self-hosted instance in `authenticated` mode, sign in as account A, which belongs to company X. 2. In a second tab, sign in as account B, which does not belong to company X. 3. Return to the first tab. The session query refetches and reports account B, while the company list is still account A's. 4. The provider keeps company X selected and leaves its id in `localStorage`. **Paperclip version or commit** `master` at `6542ad1f4`. ## What Changed - `ui/src/context/CompanyContext.tsx` — the provider observes `queryKeys.auth.session`. On a change of session user it clears the live selection, removes the shared company list, and holds auto-select until a list fetched for the new account lands. The stored id is left alone on purpose: `resolveBootstrapCompanySelection` re-validates it, so an account signing back in keeps its company while an unrelated account cannot inherit it. - `ui/src/context/CompanyContext.tsx` — an errored list is treated as undecided rather than as "no companies". With `retry: false` a single network blip sticks, and the empty-list branch read it as proof the account owns nothing and cleared the stored selection. - `ui/src/api/client.ts` — new `detachInflightGet(path)`. GET coalescing keys on the request path alone, so a `/companies` request issued under the previous session could be joined by the replacement fetch and answer it with the previous account's companies. Detaching leaves that request to settle for its own callers and makes the next call issue a fresh one. - `ui/src/api/companies.ts` — `companiesApi.detachInflightList()` wraps that for the list path. - `ui/src/context/CompanyContext.tsx` — `companyListUnavailable` separates "no usable list because a request failed" from "this account owns nothing", and `retryCompanies` gives a recovery action that fetches. Both are derived from the query rather than tracked beside it; a second copy of "did the last attempt succeed" drifted out of step during review, reporting a failure over a later empty list that was simply the truth. - `ui/src/components/SidebarCompanyMenu.tsx` — renders "Couldn't load companies" and a Try again item in place of "No companies", which is a claim about the account that a failed request cannot support. This is the menu `Sidebar` mounts, so it is the only place a customer can act on the failure. - `ui/src/components/CompanySwitcher.tsx` — the same treatment. The application does not render this component (its only mount is a Storybook story), so it is kept in step rather than relied on. - `ui/src/context/CompanyContext.test.tsx`, `ui/src/components/SidebarCompanyMenu.test.tsx`, `ui/src/api/client.test.ts` — coverage for the account switch, a same-account re-observation not churning, the detached GET, the failed replacement and its recovery, a single blip self-healing, unavailability not outliving the failure, and the sidebar rendering the recovery action for a failure but plain "No companies" for an account that owns nothing. ### No `retry` override on the replacement fetch The obvious fix for a failed replacement is a retry, and it is not load-bearing here. A transient failure already gets a second attempt: the observer rebinds to a fresh query on the render those state updates schedule, and issues its own request — measured as two attempts with or without the option. Retries would only add failed round trips before a real outage is reported, and the outage is what needs a way out, which is what `companyListUnavailable` and `retryCompanies` provide. ### Why `removeQueries` here, and why that does not generalise Removal notifies no observer. What rebinds them at this call site is the render the surrounding state updates schedule; every observer re-binds to a fresh query on the next render. A caller without that guarantee would leave mounted observers serving the previous account's value, so this is not a pattern to lift elsewhere — the sign-out sweep in #11380 must use `resetQueries` instead, and its measurements are at [#11380](https://github.com/paperclipai/paperclip/pull/11380#issuecomment-5300984911). The inverse caveat holds for a local reset under an observer that stays mounted, which is why #11417 and #11382 avoid `resetQueries`. ## Verification - `pnpm vitest run` in `ui`: **4014 passed, 1 failed**. - `pnpm tsc -b` in `ui`: clean. - `CompanyContext.test.tsx`: 16 passed. `SidebarCompanyMenu.test.tsx`: 15 passed. `client.test.ts`: 9 passed. The failure is pre-existing and unrelated: `IssueProperties.test.tsx` expects `4:08 PM` and gets `9:08 AM`, a timezone-dependent assertion. It reproduces on a tree without this change, and #11478 fixes it. Each new test was confirmed to fail against the implementation it covers, by reverting that change and re-running rather than by assuming. The account-switch test fails without the fix (the selection stays on the previous account's company and no refetch is issued); the flag-clearing test fails without its clause (an empty list keeps reading as "couldn't load"). **Not done:** no manual two-account run in a browser. The path needs two accounts on an `authenticated` instance, which a local dev instance cannot exercise. ## Risks Low. The failure direction is a company selection withheld for one extra round trip, which resolves when the list arrives. The direction it removes is one account's company selected and persisted for another. It adds one company-list request per account change, on a query key the app already uses. It adds no request at boot: the session query it observes is already fetched app-wide. **This does not close the class.** Company-scoped entries other than the list — `["companies", id]`, stats, and the rest of the per-account cache — still survive an account change. That is the cache-lifetime work in #11380, not this provider's. ## Model Used Claude Opus 5 (`claude-opus-5`), through Claude Code. Extended thinking enabled. Tool use enabled: file read and edit, shell for typecheck and test runs, and a scratch vitest harness to measure `removeQueries` and `resetQueries` notification behaviour against the installed `@tanstack/query-core` 5.101.4. ## 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 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
517 lines
19 KiB
TypeScript
517 lines
19 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 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,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { useCompany } from "@/context/CompanyContext";
|
|
import { useDialogActions } from "@/context/DialogContext";
|
|
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
|
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";
|
|
import { CompanyPatternIcon } from "./CompanyPatternIcon";
|
|
|
|
interface SidebarCompanyMenuProps {
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
}
|
|
|
|
const WORKSPACE_ICON_CLASS = "size-5 shrink-0 rounded-md text-(length:--text-micro)";
|
|
const WORKSPACE_BADGE_CLASS =
|
|
"shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-(length:--text-nano) text-muted-foreground";
|
|
|
|
function WorkspaceIcon({ company }: { company: Company }) {
|
|
return (
|
|
<CompanyPatternIcon
|
|
companyName={company.name}
|
|
logoUrl={company.logoUrl}
|
|
brandColor={company.brandColor}
|
|
className={WORKSPACE_ICON_CLASS}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Cloud stacks have no hot-linkable icon URL in the portfolio payload, so v1
|
|
* renders the same deterministic monogram treatment cloud's own portfolio page
|
|
* uses — seeded by the display name, never fetched.
|
|
*/
|
|
function StackIcon({ displayName }: { displayName: string }) {
|
|
return <CompanyPatternIcon companyName={displayName} className={WORKSPACE_ICON_CLASS} />;
|
|
}
|
|
|
|
/**
|
|
* The switcher trigger on a Cloud instance. A Cloud tenant holds exactly one
|
|
* company and the harness pushes the stack's uploaded workspace icon into
|
|
* that company's branding, so the company logo is the stack logo here.
|
|
* The stack rows keep the monogram treatment above.
|
|
*/
|
|
function CurrentStackIcon({
|
|
displayName,
|
|
company,
|
|
}: {
|
|
displayName: string;
|
|
company: Company | null;
|
|
}) {
|
|
return (
|
|
<CompanyPatternIcon
|
|
companyName={displayName}
|
|
logoUrl={company?.logoUrl}
|
|
brandColor={company?.brandColor}
|
|
className={WORKSPACE_ICON_CLASS}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function CloudStackItem({
|
|
stack,
|
|
isSelected,
|
|
onSelect,
|
|
}: {
|
|
stack: CloudStackSummary;
|
|
isSelected: boolean;
|
|
onSelect: (stack: CloudStackSummary) => void;
|
|
}) {
|
|
return (
|
|
<DropdownMenuItem
|
|
onSelect={() => onSelect(stack)}
|
|
className={cn("min-w-0 gap-2 py-2", isSelected && "bg-accent text-accent-foreground")}
|
|
>
|
|
<StackIcon displayName={stack.displayName} />
|
|
<span className="min-w-0 flex-1 truncate" title={stack.displayName}>
|
|
{stack.displayName}
|
|
</span>
|
|
{/* Company badges are 3-4 character issue prefixes, but stack slugs are
|
|
user-chosen and can be long enough to truncate the name to a single
|
|
letter — the name is the primary identifier, so the badge yields. */}
|
|
<span className={cn(WORKSPACE_BADGE_CLASS, "max-w-24 truncate")} title={stack.stackSlug}>
|
|
{stack.stackSlug}
|
|
</span>
|
|
{isSelected ? <Check className="size-4 shrink-0 text-muted-foreground" /> : null}
|
|
</DropdownMenuItem>
|
|
);
|
|
}
|
|
|
|
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(
|
|
"min-w-0 gap-2 py-2",
|
|
isEditing && "cursor-grab",
|
|
isDragging && "opacity-80",
|
|
isSelected && "bg-accent text-accent-foreground",
|
|
)}
|
|
>
|
|
<WorkspaceIcon company={company} />
|
|
<span className="min-w-0 flex-1 truncate">{company.name}</span>
|
|
{isEditing ? (
|
|
<button
|
|
type="button"
|
|
ref={setActivatorNodeRef}
|
|
aria-label={`Reorder ${company.name}`}
|
|
className="inline-flex size-6 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={WORKSPACE_BADGE_CLASS}>{company.issuePrefix}</span>
|
|
{isSelected ? <Check className="size-4 text-muted-foreground" /> : null}
|
|
</>
|
|
)}
|
|
</DropdownMenuItem>
|
|
);
|
|
}
|
|
|
|
export function SidebarCompanyMenu({ 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,
|
|
});
|
|
|
|
// 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);
|
|
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 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`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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() {
|
|
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).
|
|
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"
|
|
// `px-3` (not px-2) so the logo's left edge lines up with the nav icon
|
|
// column (nav px-3 + item px-3) and, crucially, stays put between states:
|
|
// the Button's default size adds `has-[>svg]:px-3`, so with the chevron
|
|
// svg present (expanded) it was already 12px but without it (rail) it fell
|
|
// back to 8px — a 4px horizontal jump on collapse (PAP-10676).
|
|
// `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.
|
|
className="h-9 min-w-0 flex-1 justify-start gap-2 px-3 text-left"
|
|
aria-label={
|
|
currentName
|
|
? `Open ${currentName} ${switcherNoun} switcher`
|
|
: `Open ${switcherNoun} switcher`
|
|
}
|
|
>
|
|
<span className="flex min-w-0 flex-1 items-center gap-2">
|
|
{isCloud
|
|
? currentName ? <CurrentStackIcon displayName={currentName} company={selectedCompany} /> : null
|
|
: selectedCompany ? <WorkspaceIcon company={selectedCompany} /> : null}
|
|
{/* 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 ?? (isCloud ? "Select organization" : "Select company")}
|
|
</span>
|
|
</span>
|
|
{!rail && <ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" sideOffset={8} className="w-64 p-1">
|
|
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
|
<DropdownMenuLabel className="p-0 text-(length:--text-micro) font-semibold uppercase text-muted-foreground">
|
|
{isCloud ? "Switch organization" : "Switch company"}
|
|
</DropdownMenuLabel>
|
|
{/* Stack order is owned by cloud's own portfolio in v1, so the
|
|
drag-to-reorder affordance stays self-hosted-only. */}
|
|
{isCloud ? null : (
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setIsEditingOrder((current) => !current);
|
|
}}
|
|
className="rounded px-1.5 py-0.5 text-(length:--text-micro) font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
>
|
|
{isEditingOrder ? "Done" : "Edit"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="max-h-96 overflow-y-auto">
|
|
{isCloud ? (
|
|
<>
|
|
{stacks.map((stack) => (
|
|
<CloudStackItem
|
|
key={stack.stackSlug}
|
|
stack={stack}
|
|
isSelected={stack.stackSlug === currentStack?.stackSlug}
|
|
onSelect={selectStack}
|
|
/>
|
|
))}
|
|
{stacks.length === 0 ? (
|
|
<DropdownMenuItem disabled>
|
|
{stacksQuery.isLoading
|
|
? "Loading organizations..."
|
|
: stacksQuery.isError
|
|
? "Could not load organizations"
|
|
: "No organizations"}
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<>
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={orderedCompanies.map((company) => company.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
{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 companies</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={(event) => {
|
|
// Keep the menu open so the result of the retry is visible.
|
|
event.preventDefault();
|
|
void retryCompanies();
|
|
}}
|
|
>
|
|
<RefreshCw className="h-4 w-4 mr-2" />
|
|
Try again
|
|
</DropdownMenuItem>
|
|
</>
|
|
) : (
|
|
<DropdownMenuItem disabled>No companies</DropdownMenuItem>
|
|
)
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
<DropdownMenuSeparator />
|
|
{/* A cloud instance without a configured cloud origin has nowhere to
|
|
send the user, so the row (and its separator) drop out entirely. */}
|
|
{isCloud && !createStackUrl ? null : (
|
|
<>
|
|
<DropdownMenuItem
|
|
onClick={addCompany}
|
|
className="gap-2 py-2 text-muted-foreground"
|
|
disabled={isEditingOrder}
|
|
>
|
|
<Plus className="size-4" />
|
|
<span>Create new organization...</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
</>
|
|
)}
|
|
<DropdownMenuItem asChild disabled={isEditingOrder}>
|
|
<Link
|
|
to="/company/settings/invites"
|
|
onClick={(event) => {
|
|
if (isEditingOrder) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
closeNavigationChrome();
|
|
}}
|
|
>
|
|
<UserPlus className="size-4" />
|
|
<span className="truncate">
|
|
{currentName ? `Invite people to ${currentName}` : "Invite people"}
|
|
</span>
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
{session?.session ? (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onClick={() => signOutMutation.mutate()}
|
|
disabled={isEditingOrder || signOutMutation.isPending}
|
|
>
|
|
<LogOut className="size-4" />
|
|
<span>{signOutMutation.isPending ? "Signing out..." : "Sign out"}</span>
|
|
</DropdownMenuItem>
|
|
</>
|
|
) : null}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|