Files
paperclip/ui/src/pages/PluginManager.tsx
T
Dotta 76c88e5855 [codex] Move instance settings under company settings (#7680)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Operators manage both company-scoped configuration and
instance-level runtime/admin settings from the board UI
> - Instance settings previously lived as their own top-level sidebar
area, separate from the company settings context operators already use
> - That split made settings navigation feel heavier and made instance
configuration less discoverable from the settings tab
> - This pull request moves instance settings under company settings
while preserving the existing instance settings routes and plugin/admin
surfaces
> - The benefit is a smaller primary sidebar and a more coherent
settings hierarchy for operators

## Linked Issues or Issue Description

- Refs #338
- Internal: PAP-10491, PAP-10538

## What Changed

- Moved instance settings navigation under the company settings area.
- Added route helpers and sidebar entries for nested instance settings
paths.
- Updated plugin/admin settings routes to use the company settings
instance scope.
- Preserved legacy instance-settings bookmarks through compatibility
redirects that keep the active company prefix.
- Updated focused UI and plugin tests for the new navigation shape.
- Stabilized the process-loss retry test that was failing the serialized
server shard in CI.
- Rebased the branch onto current `paperclipai/paperclip` `master` and
pushed the current head.

## Verification

- `pnpm exec vitest run
ui/src/components/CompanySettingsSidebar.test.tsx
ui/src/components/access/CompanySettingsNav.test.tsx
ui/src/lib/instance-settings.test.ts
ui/src/components/InstanceSidebar.test.tsx
ui/src/components/Layout.test.tsx
ui/src/components/SidebarAccountMenu.test.tsx
ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts
packages/shared/src/validators/plugin.test.ts`
- `pnpm exec vitest run ui/src/lib/instance-settings.test.ts
ui/src/components/CompanySettingsSidebar.test.tsx
ui/src/components/access/CompanySettingsNav.test.tsx
ui/src/components/Layout.test.tsx ui/src/plugins/bridge.test.ts`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts -t "queues
exactly one retry when the recorded local pid is dead"`
- `pnpm test:run:serialized -- --shard-index 0 --shard-count 4`
- GitHub PR checks are green on head
`fe7b0955169dcae55cbe10889c1876a70ab0b80c`, including `verify`, `General
tests (server)`, all serialized server shards, build, e2e, policy,
security checks, and Greptile.
- Confirmed the PR diff does not include `pnpm-lock.yaml` or
`.github/workflows` changes.

## Risks

- Medium UI/navigation risk: instance settings links are intentionally
moving under company settings, so stale external bookmarks to legacy
paths rely on the compatibility routing in this branch.
- Low test-only risk from the CI stabilization commit: it makes the
recovery assertion select the actual retry run by `retryOfRunId` instead
of whichever non-original run appears first.
- No database migrations.
- No dependency lockfile or workflow changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent based on GPT-5, with shell/tool execution in
a local repository worktree. Exact context window was not exposed by the
runtime.

## 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 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
- [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-06-07 17:23:53 -05:00

553 lines
24 KiB
TypeScript

/**
* @fileoverview Plugin Manager page — admin UI for discovering,
* installing, enabling/disabling, and uninstalling plugins.
*
* @see PLUGIN_SPEC.md §9 — Plugin Marketplace / Manager
*/
import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { PluginRecord } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { AlertTriangle, FlaskConical, Plus, Power, Puzzle, Settings, Trash } from "lucide-react";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToastActions } from "@/context/ToastContext";
import { cn } from "@/lib/utils";
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value
.split(/\r?\n/)
.map((entry) => entry.trim())
.find(Boolean);
return line ?? null;
}
function getPluginErrorSummary(plugin: PluginRecord): string {
return firstNonEmptyLine(plugin.lastError) ?? "Plugin entered an error state without a stored error message.";
}
function isExperimentalPluginIdentity(input: {
packageName?: string | null;
packagePath?: string | null;
manifestJson?: PluginRecord["manifestJson"] | null;
bundledExperimental?: boolean;
}) {
if (input.bundledExperimental) return true;
const packageName = input.packageName ?? "";
const packagePath = input.packagePath ?? "";
if (packageName.includes("sandbox") || packagePath.includes("sandbox")) return true;
return input.manifestJson?.environmentDrivers?.some((driver) => driver.kind === "sandbox_provider") === true;
}
function ExperimentalBadge() {
return (
<Badge
variant="outline"
className="border-amber-500/30 bg-amber-500/10 text-amber-700 hover:bg-amber-500/10 dark:text-amber-200"
>
Experimental
</Badge>
);
}
/**
* PluginManager page component.
*
* Provides a management UI for the Paperclip plugin system:
* - Lists all installed plugins with their status, version, and category badges.
* - Allows installing new plugins by npm package name.
* - Provides per-plugin actions: enable, disable, navigate to settings.
* - Uninstall with a two-step confirmation dialog to prevent accidental removal.
*
* Data flow:
* - Reads from `GET /api/plugins` via `pluginsApi.list()`.
* - Mutations (install / uninstall / enable / disable) invalidate
* `queryKeys.plugins.all` so the list refreshes automatically.
*
* @see PluginSettings — linked from the Settings icon on each plugin row.
* @see doc/plugins/PLUGIN_SPEC.md §3 — Plugin Lifecycle for status semantics.
*/
export function PluginManager() {
const { selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [installPackage, setInstallPackage] = useState("");
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [uninstallPluginId, setUninstallPluginId] = useState<string | null>(null);
const [uninstallPluginName, setUninstallPluginName] = useState<string>("");
const [errorDetailsPlugin, setErrorDetailsPlugin] = useState<PluginRecord | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/company/settings" },
{ label: "Instance settings", href: "/company/settings/instance/general" },
{ label: "Plugins" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
const { data: plugins, isLoading, error } = useQuery({
queryKey: queryKeys.plugins.all,
queryFn: () => pluginsApi.list(),
});
const bundledQuery = useQuery({
queryKey: queryKeys.plugins.examples,
queryFn: () => pluginsApi.listBundled(),
});
const invalidatePluginQueries = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.all });
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.examples });
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.uiContributions });
};
const installMutation = useMutation({
mutationFn: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
pluginsApi.install(params),
onSuccess: () => {
invalidatePluginQueries();
setInstallDialogOpen(false);
setInstallPackage("");
pushToast({ title: "Plugin installed successfully", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to install plugin", body: err.message, tone: "error" });
},
});
const uninstallMutation = useMutation({
mutationFn: (pluginId: string) => pluginsApi.uninstall(pluginId),
onSuccess: () => {
invalidatePluginQueries();
pushToast({ title: "Plugin uninstalled successfully", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to uninstall plugin", body: err.message, tone: "error" });
},
});
const enableMutation = useMutation({
mutationFn: (pluginId: string) => pluginsApi.enable(pluginId),
onSuccess: () => {
invalidatePluginQueries();
pushToast({ title: "Plugin enabled", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to enable plugin", body: err.message, tone: "error" });
},
});
const disableMutation = useMutation({
mutationFn: (pluginId: string) => pluginsApi.disable(pluginId),
onSuccess: () => {
invalidatePluginQueries();
pushToast({ title: "Plugin disabled", tone: "info" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to disable plugin", body: err.message, tone: "error" });
},
});
const installedPlugins = plugins ?? [];
const bundledPlugins = bundledQuery.data ?? [];
const installedByPackageName = new Map(installedPlugins.map((plugin) => [plugin.packageName, plugin]));
const bundledByPackageName = new Map(bundledPlugins.map((plugin) => [plugin.packageName, plugin]));
const errorSummaryByPluginId = useMemo(
() =>
new Map(
installedPlugins.map((plugin) => [plugin.id, getPluginErrorSummary(plugin)])
),
[installedPlugins]
);
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading plugins...</div>;
if (error) return <div className="p-4 text-sm text-destructive">Failed to load plugins.</div>;
return (
<div className="space-y-6 max-w-5xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Puzzle className="h-6 w-6 text-muted-foreground" />
<h1 className="text-xl font-semibold">Plugin Manager</h1>
</div>
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
<DialogTrigger asChild>
<Button size="sm" className="gap-2">
<Plus className="h-4 w-4" />
Install Plugin
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Install Plugin</DialogTitle>
<DialogDescription>
Enter the npm package name of the plugin you wish to install.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="packageName">npm Package Name</Label>
<Input
id="packageName"
placeholder="@paperclipai/plugin-example"
value={installPackage}
onChange={(e) => setInstallPackage(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setInstallDialogOpen(false)}>Cancel</Button>
<Button
onClick={() => installMutation.mutate({ packageName: installPackage })}
disabled={!installPackage || installMutation.isPending}
>
{installMutation.isPending ? "Installing..." : "Install"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
<div className="space-y-1 text-sm">
<p className="font-medium text-foreground">Plugins are alpha.</p>
<p className="text-muted-foreground">
The plugin runtime and API surface are still changing. Expect breaking changes while this feature settles.
</p>
</div>
</div>
</div>
<section className="space-y-3">
<div className="flex items-center gap-2">
<FlaskConical className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">Available Plugins</h2>
<Badge variant="outline">Bundled</Badge>
</div>
{bundledQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Loading bundled plugins...</div>
) : bundledQuery.error ? (
<div className="text-sm text-destructive">Failed to load bundled plugins.</div>
) : bundledPlugins.length === 0 ? (
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
No bundled plugins were found in this checkout.
</div>
) : (
<ul className="divide-y rounded-md border bg-card">
{bundledPlugins.map((bundledPlugin) => {
const installedPlugin = installedByPackageName.get(bundledPlugin.packageName);
const installPending =
installMutation.isPending &&
installMutation.variables?.isLocalPath &&
installMutation.variables.packageName === bundledPlugin.localPath;
return (
<li key={bundledPlugin.packageName}>
<div className="flex items-center gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{bundledPlugin.displayName}</span>
<Badge variant="outline">
{bundledPlugin.tag === "first-party" ? "First-party" : "Example"}
</Badge>
{isExperimentalPluginIdentity({
packageName: bundledPlugin.packageName,
packagePath: bundledPlugin.localPath,
bundledExperimental: bundledPlugin.experimental,
}) && <ExperimentalBadge />}
{installedPlugin ? (
<Badge
variant={installedPlugin.status === "ready" ? "default" : "secondary"}
className={installedPlugin.status === "ready" ? "bg-green-600 hover:bg-green-700" : ""}
>
{installedPlugin.status}
</Badge>
) : (
<Badge variant="secondary">Not installed</Badge>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">{bundledPlugin.description}</p>
<p className="mt-1 text-xs text-muted-foreground">{bundledPlugin.packageName}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{installedPlugin ? (
<>
{installedPlugin.status !== "ready" && (
<Button
variant="outline"
size="sm"
disabled={enableMutation.isPending}
onClick={() => enableMutation.mutate(installedPlugin.id)}
>
Enable
</Button>
)}
<Button variant="outline" size="sm" asChild>
<Link to={`/company/settings/instance/plugins/${installedPlugin.id}`}>
{installedPlugin.status === "ready" ? "Open Settings" : "Review"}
</Link>
</Button>
</>
) : (
<Button
size="sm"
disabled={installPending || installMutation.isPending}
onClick={() =>
installMutation.mutate({
packageName: bundledPlugin.localPath,
isLocalPath: true,
})
}
>
{installPending ? "Installing..." : "Install"}
</Button>
)}
</div>
</div>
</li>
);
})}
</ul>
)}
</section>
<section className="space-y-3">
<div className="flex items-center gap-2">
<Puzzle className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">Installed Plugins</h2>
</div>
{!installedPlugins.length ? (
<Card className="bg-muted/30">
<CardContent className="flex flex-col items-center justify-center py-10">
<Puzzle className="h-10 w-10 text-muted-foreground mb-4" />
<p className="text-sm font-medium">No plugins installed</p>
<p className="text-xs text-muted-foreground mt-1">
Install a plugin to extend functionality.
</p>
</CardContent>
</Card>
) : (
<ul className="divide-y rounded-md border bg-card">
{installedPlugins.map((plugin) => (
<li key={plugin.id}>
<div className="flex items-start gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/company/settings/instance/plugins/${plugin.id}`}
className="font-medium hover:underline truncate block"
title={plugin.manifestJson.displayName ?? plugin.packageName}
>
{plugin.manifestJson.displayName ?? plugin.packageName}
</Link>
{bundledByPackageName.has(plugin.packageName) && (
<Badge variant="outline">
{bundledByPackageName.get(plugin.packageName)?.tag === "first-party"
? "First-party"
: "Example"}
</Badge>
)}
{isExperimentalPluginIdentity({
packageName: plugin.packageName,
packagePath: plugin.packagePath,
manifestJson: plugin.manifestJson,
bundledExperimental: bundledByPackageName.get(plugin.packageName)?.experimental,
}) && <ExperimentalBadge />}
</div>
<div>
<p className="text-xs text-muted-foreground mt-0.5 truncate" title={plugin.packageName}>
{plugin.packageName} · v{plugin.manifestJson.version ?? plugin.version}
</p>
</div>
<p className="text-sm text-muted-foreground truncate mt-0.5" title={plugin.manifestJson.description}>
{plugin.manifestJson.description || "No description provided."}
</p>
{plugin.status === "error" && (
<div className="mt-3 rounded-md border border-red-500/25 bg-red-500/[0.06] px-3 py-2">
<div className="flex flex-wrap items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-red-700 dark:text-red-300">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>Plugin error</span>
</div>
<p
className="mt-1 text-sm text-red-700/90 dark:text-red-200/90 break-words"
title={plugin.lastError ?? undefined}
>
{errorSummaryByPluginId.get(plugin.id)}
</p>
</div>
<Button
variant="outline"
size="sm"
className="border-red-500/30 bg-background/60 text-red-700 hover:bg-red-500/10 hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
onClick={() => setErrorDetailsPlugin(plugin)}
>
View full error
</Button>
</div>
</div>
)}
</div>
<div className="flex shrink-0 self-center">
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2">
<Badge
variant={
plugin.status === "ready"
? "default"
: plugin.status === "error"
? "destructive"
: "secondary"
}
className={cn(
"shrink-0",
plugin.status === "ready" ? "bg-green-600 hover:bg-green-700" : ""
)}
>
{plugin.status}
</Badge>
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8"
title={plugin.status === "ready" ? "Disable" : "Enable"}
onClick={() => {
if (plugin.status === "ready") {
disableMutation.mutate(plugin.id);
} else {
enableMutation.mutate(plugin.id);
}
}}
disabled={enableMutation.isPending || disableMutation.isPending}
>
<Power className={cn("h-4 w-4", plugin.status === "ready" ? "text-green-600" : "")} />
</Button>
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8 text-destructive hover:text-destructive"
title="Uninstall"
onClick={() => {
setUninstallPluginId(plugin.id);
setUninstallPluginName(plugin.manifestJson.displayName ?? plugin.packageName);
}}
disabled={uninstallMutation.isPending}
>
<Trash className="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" className="mt-2 h-8" asChild>
<Link to={`/company/settings/instance/plugins/${plugin.id}`}>
<Settings className="h-4 w-4" />
Configure
</Link>
</Button>
</div>
</div>
</div>
</li>
))}
</ul>
)}
</section>
<Dialog
open={uninstallPluginId !== null}
onOpenChange={(open) => { if (!open) setUninstallPluginId(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Uninstall Plugin</DialogTitle>
<DialogDescription>
Are you sure you want to uninstall <strong>{uninstallPluginName}</strong>? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setUninstallPluginId(null)}>Cancel</Button>
<Button
variant="destructive"
disabled={uninstallMutation.isPending}
onClick={() => {
if (uninstallPluginId) {
uninstallMutation.mutate(uninstallPluginId, {
onSettled: () => setUninstallPluginId(null),
});
}
}}
>
{uninstallMutation.isPending ? "Uninstalling..." : "Uninstall"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={errorDetailsPlugin !== null}
onOpenChange={(open) => { if (!open) setErrorDetailsPlugin(null); }}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Error Details</DialogTitle>
<DialogDescription>
{errorDetailsPlugin?.manifestJson.displayName ?? errorDetailsPlugin?.packageName ?? "Plugin"} hit an error state.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border border-red-500/25 bg-red-500/[0.06] px-4 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-700 dark:text-red-300" />
<div className="space-y-1 text-sm">
<p className="font-medium text-red-700 dark:text-red-300">
What errored
</p>
<p className="text-red-700/90 dark:text-red-200/90 break-words">
{errorDetailsPlugin ? getPluginErrorSummary(errorDetailsPlugin) : "No error summary available."}
</p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Full error output</p>
<pre className="max-h-[50vh] overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-5 whitespace-pre-wrap break-words">
{errorDetailsPlugin?.lastError ?? "No stored error message."}
</pre>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setErrorDetailsPlugin(null)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}