forked from farhoodlabs/paperclip
f0ddd24d61
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The plugin system is how Paperclip exposes optional capabilities and integrations without bloating the control plane. > - Operators need the Instance Settings plugin manager to show both installed external plugins and bundled built-in plugins. > - Bundled plugins were available in the server/UI surface but were not represented consistently in the plugin manager list. > - Workspace runtime reuse also needed to stay pinned to the current branch/base so the plugin manager can be validated from the intended checkout. > - This pull request shows bundled plugins in the manager, marks experimental bundled plugins clearly, and tightens runtime/worktree reuse guards. > - The benefit is that operators can discover bundled plugins from the same management screen as installed plugins without stale workspace sessions hiding the latest branch state. ## What Changed - Lists bundled monorepo plugin packages through the plugin routes API, including plugin status and install metadata needed by the UI. - Updates the plugin manager UI/API client to render bundled plugins and display experimental badges based on installed plugin records. - Adds server authorization coverage around plugin routes so board and agent access stay company-scoped. - Guards execution workspace/runtime reuse against stale base refs and defaults new worktrees to the fetched target base. - Expands workspace runtime tests for service reuse, stale workspace prevention, and controlled runtime stops. - Addressed Greptile feedback by respecting `origin/HEAD`, using async cached bundled-plugin discovery, and avoiding duplicated UI experimental plugin lists. ## Verification - `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts server/src/__tests__/workspace-runtime.test.ts server/src/__tests__/heartbeat-workspace-session.test.ts` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm --filter @paperclipai/plugin-sdk build && pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/server typecheck` - `gh pr checks 6734 --repo paperclipai/paperclip` reports all checks passing on `10e1ba9e0f505637cd913713fb28c2c99ae92011`. - Greptile Review reports 5/5 on `10e1ba9e0f505637cd913713fb28c2c99ae92011`. - Confirmed the branch is rebased onto `public-gh/master` and the PR diff does not include `pnpm-lock.yaml` or `.github/workflows` changes. - UI screenshots were not captured in this PR-creation pass because the available local board runtime is authenticated; the visible UI path is covered by the plugin manager code changes and server/API tests above. ## Risks - Medium risk: this touches shared plugin listing behavior and workspace runtime reuse, so regressions could affect plugin manager visibility or service reuse across execution workspaces. - No database migrations. - No lockfile or GitHub 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 GPT-5 Codex, coding-agent workflow with shell/tool use in a local Paperclip worktree. Context window not surfaced by the runtime; reasoning mode not externally reported. ## 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 - [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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
552 lines
24 KiB
TypeScript
552 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: "/instance/settings/heartbeats" },
|
|
{ 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={`/instance/settings/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={`/instance/settings/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={`/instance/settings/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>
|
|
);
|
|
}
|