Files
PaperClipAI/ui/src/components/PluginOrganizationSwitcher.tsx
T
Devin FoleyandPaperclip e4237c45f3 fix(ui): prevent organization title flicker during plugin loading (#13854)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The sidebar identifies the current organization.
> - An optional plugin can replace this navigation surface.
> - The built-in title appears before discovery and module loading
finish.
> - This pull request reserves the trigger until its owner is known.
> - The organization name appears once, while failures retain built-in
navigation.

## Linked Issues or Issue Description

Refs #13832. Searched related pull requests and issues; no duplicate fix
found.

**What happened?**
The organization switcher renders a provisional built-in title before an
installed replacement loads. Unrelated plugin imports can also affect
its loading state.

**Expected behavior**
Reserve the trigger with a neutral placeholder, then show the resolved
navigation surface. Keep the built-in menu on failed or absent
contributions.

**Steps to reproduce**
Install an organization-switcher contribution. Delay session, company,
contribution, and module responses. Reload the page and watch the
trigger through each stage.

## What Changed

- Reserve the trigger through account, company selection, slot
discovery, and module loading.
- Distinguish failed session lookup from pending lookup so errors retain
usable navigation.
- Load and await only contributions matching the requested slots.
Observe completion of imports started by another consumer.
- Document loading behavior and add regression coverage for loading,
failures, unrelated modules, and identity transitions.

## Verification

- `pnpm -r typecheck` passed, including Rust checks.
- `pnpm build` passed.
- All 629 UI test files passed: 6,593 tests. The 42 focused
UI/API/plugin tests also passed.
- `pnpm check:token-gates` and `git diff --check` passed.
- `pnpm test:run` was also attempted. The broad local server run was
stopped after recording skill-cache/channel fixture failures outside
this diff (for example, runtime skill source status `missing` instead of
`available`). The original cause is not established. All latest-head
Linux CI gates pass; the complete UI suite and affected local checks
pass.
- Desktop (1440px) and mobile (390px) Chromium checks passed with real
host components, dynamic module loading, and the built Account bundle.
Delayed fixture responses produced exactly two title states: empty
placeholder, then the resolved name. A slow refresh preserved the title
and trigger dimensions; absent/failed plugin fallback and Escape
dismissal passed, with zero uncaught browser errors. This is browser
component integration, not a live signed-in tenant test.

## Risks

A cold load displays a neutral placeholder until discovery completes.
Absent, ambiguous, failed, and invalid contributions still use the
built-in menu. No migrations or authorization changes. Scoped module
loading changes when an unrelated contribution is imported; each surface
loads its own matching modules.

## Model Used

OpenAI Codex, GPT-6, with reasoning, code execution, and browser
verification. The exact deployment ID and context window are not exposed
in 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>
2026-09-22 20:33:29 -07:00

83 lines
4.3 KiB
TypeScript

import { useState, type ReactNode } from "react";
import { ChevronsUpDown } from "lucide-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";
import { Skeleton } from "./ui/skeleton";
/** Reserve the trigger's space until its owner is known. Never flash another name. */
function OrganizationSwitcherLoading({ collapsed }: { collapsed: boolean }) {
return <div role="status" aria-label="Loading organization" aria-busy="true"
className="flex h-9 min-w-0 flex-1 items-center gap-2 px-4">
<Skeleton className="size-5 shrink-0" />
{!collapsed && <>
<span className="min-w-0 flex-1"><Skeleton className="h-4 w-20 max-w-full" /></span>
<ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
</>}
</div>;
}
/** Optional replacement; use the built-in menu when discovery or rendering fails. */
export function PluginOrganizationSwitcher({ children, open: controlledOpen, onOpenChange }: {
children: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const { userId, settled, failed } = 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);
const contextLoading = !contextSettled && !failed && !companyList.isError && !companyList.data?.unauthorized;
return (
<OrganizationSwitcher key={JSON.stringify([userId, selectedCompanyId])}
settled={contextSettled} loading={contextLoading} companyId={selectedCompanyId}
company={selectedCompany} open={controlledOpen} onOpenChange={onOpenChange}>
{children}
</OrganizationSwitcher>
);
}
function OrganizationSwitcher({ children, settled, loading, companyId, company, open: controlledOpen, onOpenChange }: {
children: ReactNode; settled: boolean; loading: 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, isLoading, errorMessage } = usePluginSlots({ slotTypes: ["organizationSwitcher"], companyId, enabled: settled });
if (loading || (settled && isLoading && !errorMessage)) {
return <OrganizationSwitcherLoading collapsed={collapsed && !peeking} />;
}
// 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) => <CompanyPatternIcon companyName={name} logoUrl={logoUrl}
className={inMenu
? "size-(--organization-popover-avatar-size) shrink-0 rounded-lg text-(length:--text-micro)"
: "size-5 shrink-0 rounded-md text-(length:--text-micro)"} />,
},
};
return <PluginSlotMount slot={slots[0]!}
context={{ companyId, companyPrefix: company?.issuePrefix ?? null }}
componentProps={{ ...props }} fallback={children} />;
}