mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 03:16:24 +02:00
## 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>
110 lines
7.9 KiB
TypeScript
110 lines
7.9 KiB
TypeScript
// @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, failed: false, isLoading: false, companyListError: false, 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, isError: state.companyListError, 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: () => <span>icon</span> }));
|
|
vi.mock("@/plugins/slots", async (importOriginal) => ({ ...await importOriginal<object>(), 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(<QueryClientProvider client={client}><PluginOrganizationSwitcher><button>Built-in organizations</button></PluginOrganizationSwitcher></QueryClientProvider>));
|
|
}
|
|
afterEach(() => {
|
|
if (root) flushSync(() => root!.unmount()); root = undefined; container?.remove(); client.clear(); vi.restoreAllMocks();
|
|
Object.assign(state, { userId: "alice", companyId: "company-a", settled: true, failed: false, isLoading: false, companyListError: false, 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 <button onClick={() => setSelected(true)}>{selected ? "selected" : "Private organizations"}</button>;
|
|
});
|
|
state.slots = [slot];
|
|
}
|
|
describe("organization navigation replacement", () => {
|
|
it("reserves the trigger until identity, companies, discovery and the module are ready", () => {
|
|
state.settled = false;
|
|
render();
|
|
expect(container.textContent).toBe("");
|
|
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
|
|
state.settled = true; state.companyListReady = false; render();
|
|
expect(container.textContent).toBe("");
|
|
state.companyListReady = true; state.isLoading = true; render();
|
|
expect(container.textContent).toBe("");
|
|
state.slots = [{ ...slot, exportName: "Delayed" }]; render();
|
|
expect(container.textContent).toBe("");
|
|
registerPluginReactComponent(slot.pluginKey, "Delayed", () => <button>Resolved organization</button>);
|
|
state.isLoading = false; render();
|
|
expect(container.textContent).toBe("Resolved organization");
|
|
expect(container.querySelector('[aria-busy="true"]')).toBeNull();
|
|
});
|
|
it("keeps built-in navigation for absent, ambiguous or failed 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; state.failed = true; render(); expect(container.textContent).toBe("Built-in organizations");
|
|
state.failed = false; state.settled = true; state.companyListReady = false; state.companyListError = true;
|
|
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.querySelector('[aria-label="Loading organization"]')).not.toBeNull();
|
|
expect(container.textContent).toBe("");
|
|
expect(state.props).toBeNull();
|
|
state.companyListReady = true;
|
|
state.companyIds = ["company-b"];
|
|
render();
|
|
expect(container.querySelector('[aria-label="Loading organization"]')).not.toBeNull();
|
|
expect(container.textContent).toBe("");
|
|
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");
|
|
});
|
|
});
|