fix(ui): hide sandbox-provider plugins from Instance Settings sidebar (#6341)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Plugins extend Paperclip with new capabilities; the Instance Settings sidebar exposes per-plugin settings pages under a "Plugins" group > - Some plugins contribute only sandbox-provider drivers (E2B, exe.dev, Modal). They have no per-plugin settings UI — `PluginSettings` already redirects sandbox-provider-only plugins to the Environments page > - As a result, listing them as their own sidebar rows produced confusing entries that visually nested below the "Adapters" group and only lead to a stub redirect — there was no value to the user > - This pull request hides sandbox-provider-only plugins from the Instance Settings sidebar, and reorders the indented plugin list so it sits directly under the "Plugins" group it actually belongs to > - The benefit is a cleaner sidebar that only surfaces plugins with real per-plugin settings, and removes the visual mis-nesting under Adapters ## What Changed - `ui/src/components/InstanceSidebar.tsx`: filter out plugins whose only contribution is `sandboxProviders` (hybrid plugins that contribute sandbox providers *plus* something else still get a sidebar entry). Move the indented plugin list so it renders between the "Plugins" row and the "Adapters" row instead of after Adapters. - `ui/src/components/InstanceSidebar.test.tsx`: new test file with 4 cases — sandbox-only plugins hidden, hybrid plugins shown, ordering (plugin list appears under Plugins and before Adapters), and the existing non-plugin sidebar items still render. ## Verification - `pnpm -C ui vitest run src/components/InstanceSidebar.test.tsx` → 4/4 pass. - `pnpm typecheck` clean on the changed files. - Manual: visit `/instance/settings/plugins` — "E2B Sandbox Provider" and "exe.dev Sandbox Provider" rows no longer appear in the sidebar; remaining plugins are listed directly under the "Plugins" group, not below Adapters. **Before:** see the screenshot embedded in the linked issue (`PAPA-375`) — sandbox-provider plugins show as sidebar rows visually nested under "Adapters". **After:** sandbox-provider-only rows are gone; plugin list sits directly under the "Plugins" group. (A live runtime screenshot was not captured for this PR because the local server requires an authenticated browser session not currently available to the agent — happy to add one on request.) ## Risks - Low risk. Pure UI filter + reorder, scoped to `InstanceSidebar.tsx`. No backend or plugin-loader changes. Hybrid plugins that legitimately need a settings entry are explicitly preserved by the filter. Covered by 4 new unit tests. ## Model Used - Claude Opus 4.7 (`claude-opus-4-7`), Anthropic, via Claude Code in a Paperclip heartbeat. Standard tool-use mode (no extended thinking). 200K context. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — before is in the linked issue; after pending live capture (see Verification) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Closes PAPA-375. Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, type ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRecord } from "@paperclipai/shared";
|
||||
|
||||
const mockPluginsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/plugins", () => ({
|
||||
pluginsApi: mockPluginsApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
NavLink: ({
|
||||
children,
|
||||
to,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode | ((arg: { isActive: boolean }) => ReactNode);
|
||||
to: string;
|
||||
state?: unknown;
|
||||
end?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string | ((arg: { isActive: boolean }) => string);
|
||||
}) => {
|
||||
const resolvedClass =
|
||||
typeof className === "function" ? className({ isActive: false }) : className;
|
||||
const content = typeof children === "function" ? children({ isActive: false }) : children;
|
||||
return (
|
||||
<a href={to} className={resolvedClass}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({ isMobile: false, setSidebarOpen: vi.fn() }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
import { InstanceSidebar } from "./InstanceSidebar";
|
||||
|
||||
function makePlugin(overrides: Partial<PluginRecord> & { manifestJson: PluginRecord["manifestJson"] }): PluginRecord {
|
||||
return {
|
||||
id: overrides.id ?? "plugin-id",
|
||||
pluginKey: overrides.pluginKey ?? "plugin-key",
|
||||
packageName: overrides.packageName ?? "@scope/pkg",
|
||||
version: overrides.version ?? "1.0.0",
|
||||
apiVersion: overrides.apiVersion ?? 1,
|
||||
categories: overrides.categories ?? [],
|
||||
manifestJson: overrides.manifestJson,
|
||||
status: overrides.status ?? "ready",
|
||||
installOrder: overrides.installOrder ?? 0,
|
||||
packagePath: overrides.packagePath ?? null,
|
||||
lastError: overrides.lastError ?? null,
|
||||
installedAt: overrides.installedAt ?? new Date(0),
|
||||
updatedAt: overrides.updatedAt ?? new Date(0),
|
||||
};
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function renderSidebar(container: HTMLElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<InstanceSidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
return { root, queryClient };
|
||||
}
|
||||
|
||||
describe("InstanceSidebar", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null;
|
||||
let queryClient: QueryClient | null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
queryClient = null;
|
||||
mockPluginsApi.list.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
const currentRoot = root;
|
||||
await act(async () => {
|
||||
currentRoot.unmount();
|
||||
});
|
||||
}
|
||||
queryClient?.clear();
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("filters out sandbox-provider-only plugins from the sidebar", async () => {
|
||||
const sandboxPlugin = makePlugin({
|
||||
id: "e2b",
|
||||
packageName: "@paperclipai/plugin-e2b",
|
||||
manifestJson: {
|
||||
id: "e2b",
|
||||
name: "E2B Sandbox Provider",
|
||||
displayName: "E2B Sandbox Provider",
|
||||
version: "1.0.0",
|
||||
apiVersion: 1,
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "e2b",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "E2B",
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
} as unknown as PluginRecord["manifestJson"],
|
||||
});
|
||||
const regularPlugin = makePlugin({
|
||||
id: "linear",
|
||||
packageName: "@paperclipai/plugin-linear",
|
||||
manifestJson: {
|
||||
id: "linear",
|
||||
name: "Linear",
|
||||
displayName: "Linear",
|
||||
version: "1.0.0",
|
||||
apiVersion: 1,
|
||||
} as unknown as PluginRecord["manifestJson"],
|
||||
});
|
||||
mockPluginsApi.list.mockResolvedValue([sandboxPlugin, regularPlugin]);
|
||||
|
||||
const rendered = renderSidebar(container);
|
||||
root = rendered.root;
|
||||
queryClient = rendered.queryClient;
|
||||
await flushReact();
|
||||
|
||||
const pluginLinks = Array.from(container.querySelectorAll('a[href^="/instance/settings/plugins/"]'));
|
||||
expect(pluginLinks).toHaveLength(1);
|
||||
expect(pluginLinks[0]?.getAttribute("href")).toBe("/instance/settings/plugins/linear");
|
||||
expect(pluginLinks[0]?.textContent).toBe("Linear");
|
||||
});
|
||||
|
||||
it("keeps plugins that mix sandbox-provider with other contributions", async () => {
|
||||
const hybridPlugin = makePlugin({
|
||||
id: "hybrid",
|
||||
packageName: "@example/plugin-hybrid",
|
||||
manifestJson: {
|
||||
id: "hybrid",
|
||||
name: "Hybrid",
|
||||
displayName: "Hybrid",
|
||||
version: "1.0.0",
|
||||
apiVersion: 1,
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "sb",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "SB",
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
{
|
||||
driverKey: "env",
|
||||
kind: "environment_driver",
|
||||
displayName: "Env",
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
} as unknown as PluginRecord["manifestJson"],
|
||||
});
|
||||
mockPluginsApi.list.mockResolvedValue([hybridPlugin]);
|
||||
|
||||
const rendered = renderSidebar(container);
|
||||
root = rendered.root;
|
||||
queryClient = rendered.queryClient;
|
||||
await flushReact();
|
||||
|
||||
const pluginLinks = Array.from(container.querySelectorAll('a[href^="/instance/settings/plugins/"]'));
|
||||
expect(pluginLinks).toHaveLength(1);
|
||||
expect(pluginLinks[0]?.getAttribute("href")).toBe("/instance/settings/plugins/hybrid");
|
||||
});
|
||||
|
||||
it("renders the indented plugin list between the Plugins and Adapters rows", async () => {
|
||||
mockPluginsApi.list.mockResolvedValue([
|
||||
makePlugin({
|
||||
id: "linear",
|
||||
packageName: "@paperclipai/plugin-linear",
|
||||
manifestJson: {
|
||||
id: "linear",
|
||||
name: "Linear",
|
||||
displayName: "Linear",
|
||||
version: "1.0.0",
|
||||
apiVersion: 1,
|
||||
} as unknown as PluginRecord["manifestJson"],
|
||||
}),
|
||||
]);
|
||||
|
||||
const rendered = renderSidebar(container);
|
||||
root = rendered.root;
|
||||
queryClient = rendered.queryClient;
|
||||
await flushReact();
|
||||
|
||||
const topLevelLinks = Array.from(
|
||||
container.querySelectorAll<HTMLAnchorElement>('a[href^="/instance/settings/"]'),
|
||||
);
|
||||
const hrefs = topLevelLinks.map((a) => a.getAttribute("href"));
|
||||
|
||||
const pluginsIndex = hrefs.indexOf("/instance/settings/plugins");
|
||||
const adaptersIndex = hrefs.indexOf("/instance/settings/adapters");
|
||||
const linearIndex = hrefs.indexOf("/instance/settings/plugins/linear");
|
||||
|
||||
expect(pluginsIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(adaptersIndex).toBeGreaterThan(pluginsIndex);
|
||||
expect(linearIndex).toBeGreaterThan(pluginsIndex);
|
||||
expect(linearIndex).toBeLessThan(adaptersIndex);
|
||||
});
|
||||
|
||||
it("does not render the indented group when every plugin is filtered out", async () => {
|
||||
mockPluginsApi.list.mockResolvedValue([
|
||||
makePlugin({
|
||||
id: "e2b",
|
||||
packageName: "@paperclipai/plugin-e2b",
|
||||
manifestJson: {
|
||||
id: "e2b",
|
||||
name: "E2B",
|
||||
displayName: "E2B",
|
||||
version: "1.0.0",
|
||||
apiVersion: 1,
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "e2b",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "E2B",
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
} as unknown as PluginRecord["manifestJson"],
|
||||
}),
|
||||
]);
|
||||
|
||||
const rendered = renderSidebar(container);
|
||||
root = rendered.root;
|
||||
queryClient = rendered.queryClient;
|
||||
await flushReact();
|
||||
|
||||
const pluginLinks = Array.from(container.querySelectorAll('a[href^="/instance/settings/plugins/"]'));
|
||||
expect(pluginLinks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,32 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Clock3, Cpu, FlaskConical, Puzzle, Settings, Shield, SlidersHorizontal, UserRoundPen } from "lucide-react";
|
||||
import type { PluginRecord } from "@paperclipai/shared";
|
||||
import { NavLink } from "@/lib/router";
|
||||
import { pluginsApi } from "@/api/plugins";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
|
||||
/**
|
||||
* Sandbox-provider-only plugins (e.g. E2B, exe.dev, Modal) have no per-plugin
|
||||
* settings page — `PluginSettings` redirects them to the Environments page —
|
||||
* so a sidebar entry would lead nowhere useful. Filter them out here. Plugins
|
||||
* that mix a sandbox provider with other contributions still appear.
|
||||
*/
|
||||
function isSandboxProviderOnly(plugin: PluginRecord): boolean {
|
||||
const drivers = plugin.manifestJson.environmentDrivers ?? [];
|
||||
if (drivers.length === 0) return false;
|
||||
return drivers.every((d) => d.kind === "sandbox_provider");
|
||||
}
|
||||
|
||||
export function InstanceSidebar() {
|
||||
const { data: plugins } = useQuery({
|
||||
queryKey: queryKeys.plugins.all,
|
||||
queryFn: () => pluginsApi.list(),
|
||||
});
|
||||
|
||||
const sidebarPlugins = (plugins ?? []).filter((p) => !isSandboxProviderOnly(p));
|
||||
|
||||
return (
|
||||
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<div className="flex items-center gap-2 px-3 h-12 shrink-0">
|
||||
@@ -29,10 +44,9 @@ export function InstanceSidebar() {
|
||||
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
|
||||
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
|
||||
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
|
||||
<SidebarNavItem to="/instance/settings/adapters" label="Adapters" icon={Cpu} />
|
||||
{(plugins ?? []).length > 0 ? (
|
||||
{sidebarPlugins.length > 0 ? (
|
||||
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">
|
||||
{(plugins ?? []).map((plugin) => (
|
||||
{sidebarPlugins.map((plugin) => (
|
||||
<NavLink
|
||||
key={plugin.id}
|
||||
to={`/instance/settings/plugins/${plugin.id}`}
|
||||
@@ -51,6 +65,7 @@ export function InstanceSidebar() {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<SidebarNavItem to="/instance/settings/adapters" label="Adapters" icon={Cpu} />
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user