Files
fusion/packages/dashboard/app/components/PluginManager.tsx
Fusion 5c105d0f03 feat(FN-3261): wire droid bundled plugin and improve agent detail view
Merged four step commits bringing AgentDetailView improvements with model favoritism UI, bundled plugin install wiring for the droid runtime, aligned documentation between settings reference and the plugin README, plus mobile layout refinements with safe-area padding test updates.

Fusion-Task-Id: FN-3261
2026-05-04 09:20:07 -07:00

683 lines
27 KiB
TypeScript

/**
* Plugin Manager Component
*
* Provides UI for managing installed plugins:
* - List installed plugins with state indicators
* - Install plugins from local paths
* - Enable/disable plugins
* - Configure plugin settings
* - Uninstall plugins
* - Live updates via SSE (plugin:lifecycle events)
*/
import "./PluginManager.css";
import { useState, useEffect, useCallback, useRef } from "react";
import { Package, Settings, Trash2, Plus, X, RefreshCw, RotateCcw, ExternalLink } from "lucide-react";
import { fetchPlugins, installPlugin, enablePlugin, disablePlugin, uninstallPlugin, fetchPluginSettings, updatePluginSettings, reloadPlugin } from "../api";
import { DirectoryPicker } from "./DirectoryPicker";
import type { PluginInstallation, PluginState } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm";
import { subscribeSse } from "../sse-bus";
/** Normalized plugin lifecycle payload from SSE plugin:lifecycle events */
interface PluginLifecyclePayload {
pluginId: string;
transition: "installing" | "enabled" | "disabled" | "error" | "uninstalled" | "settings-updated";
sourceEvent: string;
timestamp: string;
projectId?: string;
enabled: boolean;
state: PluginState;
version: string;
settings: Record<string, unknown>;
error?: string;
}
interface PluginManagerProps {
addToast: (message: string, type?: ToastType) => void;
projectId?: string;
}
interface BundledPlugin {
id: string;
name: string;
path: string;
experimental?: boolean;
}
const BUNDLED_PLUGINS: BundledPlugin[] = [
{
id: "fusion-plugin-agent-browser-runtime",
name: "Agent Browser Runtime",
path: "./plugins/fusion-plugin-agent-browser-runtime",
experimental: true,
},
{
id: "fusion-plugin-hermes-runtime",
name: "Hermes Runtime",
path: "./plugins/fusion-plugin-hermes-runtime",
experimental: true,
},
{
id: "fusion-plugin-paperclip-runtime",
name: "Paperclip Runtime",
path: "./plugins/fusion-plugin-paperclip-runtime",
},
{
id: "fusion-plugin-openclaw-runtime",
name: "OpenClaw Runtime",
path: "./plugins/fusion-plugin-openclaw-runtime",
experimental: true,
},
{
id: "fusion-plugin-droid-runtime",
name: "Droid Runtime",
path: "./plugins/fusion-plugin-droid-runtime",
experimental: true,
},
];
export const STATE_COLORS: Record<string, string> = {
started: "var(--color-success)",
loaded: "var(--color-warning)",
error: "var(--color-error)",
stopped: "var(--color-muted)",
installed: "var(--color-info)",
};
export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const [plugins, setPlugins] = useState<PluginInstallation[]>([]);
const [loading, setLoading] = useState(true);
const [showInstall, setShowInstall] = useState(false);
const [installPath, setInstallPath] = useState("");
const [installing, setInstalling] = useState(false);
const [reloadingPluginId, setReloadingPluginId] = useState<string | null>(null);
const [selectedPlugin, setSelectedPlugin] = useState<PluginInstallation | null>(null);
const [pluginSettings, setPluginSettings] = useState<Record<string, unknown>>({});
const [settingsLoading, setSettingsLoading] = useState(false);
const [installingBundledPluginId, setInstallingBundledPluginId] = useState<string | null>(null);
const { confirm } = useConfirm();
const loadPlugins = useCallback(async () => {
try {
setLoading(true);
const data = await fetchPlugins(projectId);
setPlugins(data);
} catch (err) {
addToast(`Failed to load plugins: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setLoading(false);
}
}, [projectId, addToast]);
useEffect(() => {
loadPlugins();
}, [loadPlugins]);
// SSE live updates for plugin lifecycle events
const pluginsRef = useRef<PluginInstallation[]>([]);
pluginsRef.current = plugins;
useEffect(() => {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const handlePluginLifecycle = (e: MessageEvent) => {
try {
const payload: PluginLifecyclePayload = JSON.parse(e.data);
// Filter by projectId if in project-scoped mode
if (projectId && payload.projectId && payload.projectId !== projectId) {
return;
}
switch (payload.transition) {
case "installing":
case "enabled":
case "disabled":
case "settings-updated":
// Update existing plugin or add if new
setPlugins((prev) => {
const existingIndex = prev.findIndex((p) => p.id === payload.pluginId);
if (existingIndex >= 0) {
// Update existing plugin
const updated = [...prev];
updated[existingIndex] = {
...updated[existingIndex],
enabled: payload.enabled,
state: payload.state,
settings: payload.settings,
error: payload.error,
};
return updated;
} else {
// New plugin added via another session — refetch to get full data
void loadPlugins();
return prev;
}
});
break;
case "uninstalled":
// Remove plugin from list
setPlugins((prev) => prev.filter((p) => p.id !== payload.pluginId));
break;
case "error":
// Update plugin state to error
setPlugins((prev) => {
const existingIndex = prev.findIndex((p) => p.id === payload.pluginId);
if (existingIndex >= 0) {
const updated = [...prev];
updated[existingIndex] = {
...updated[existingIndex],
state: payload.state,
error: payload.error,
};
return updated;
}
return prev;
});
break;
}
} catch {
// Ignore parse errors
}
};
return subscribeSse(`/api/events${query}`, {
events: { "plugin:lifecycle": handlePluginLifecycle },
onReconnect: () => {
// Re-sync plugin list after a forced reconnect — any events that
// occurred while disconnected would otherwise be missed.
void loadPlugins();
},
});
}, [projectId, loadPlugins]);
const handleInstall = async () => {
if (!installPath.trim()) {
addToast("Please enter a plugin path", "error");
return;
}
try {
setInstalling(true);
await installPlugin({ path: installPath }, projectId);
addToast("Plugin installed successfully", "success");
setShowInstall(false);
setInstallPath("");
await loadPlugins();
} catch (err) {
addToast(`Failed to install plugin: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setInstalling(false);
}
};
const handleInstallBundledPlugin = async (plugin: BundledPlugin) => {
try {
setInstallingBundledPluginId(plugin.id);
await installPlugin({ path: plugin.path }, projectId);
addToast(`${plugin.name} installed successfully`, "success");
await loadPlugins();
} catch (err) {
addToast(`Failed to install ${plugin.name}: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setInstallingBundledPluginId(null);
}
};
const handleEnable = async (plugin: PluginInstallation) => {
try {
await enablePlugin(plugin.id, projectId);
addToast(`${plugin.name} enabled`, "success");
await loadPlugins();
} catch (err) {
addToast(`Failed to enable plugin: ${err instanceof Error ? err.message : String(err)}`, "error");
}
};
const handleDisable = async (plugin: PluginInstallation) => {
try {
await disablePlugin(plugin.id, projectId);
addToast(`${plugin.name} disabled`, "success");
await loadPlugins();
} catch (err) {
addToast(`Failed to disable plugin: ${err instanceof Error ? err.message : String(err)}`, "error");
}
};
const handleReload = async (plugin: PluginInstallation) => {
try {
setReloadingPluginId(plugin.id);
await reloadPlugin(plugin.id, projectId);
addToast(`${plugin.name} reloaded`, "success");
await loadPlugins();
} catch (err) {
addToast(`Failed to reload plugin: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setReloadingPluginId(null);
}
};
const handleUninstall = async (plugin: PluginInstallation) => {
const shouldUninstall = await confirm({
title: "Uninstall Plugin",
message: `Are you sure you want to uninstall "${plugin.name}"?`,
danger: true,
});
if (!shouldUninstall) {
return;
}
try {
await uninstallPlugin(plugin.id, projectId);
addToast(`${plugin.name} uninstalled`, "success");
await loadPlugins();
setSelectedPlugin(null);
} catch (err) {
addToast(`Failed to uninstall plugin: ${err instanceof Error ? err.message : String(err)}`, "error");
}
};
const handleSelectPlugin = async (plugin: PluginInstallation) => {
setSelectedPlugin(plugin);
try {
setSettingsLoading(true);
const settings = await fetchPluginSettings(plugin.id, projectId);
setPluginSettings(settings);
} catch {
setPluginSettings({});
} finally {
setSettingsLoading(false);
}
};
const handleSaveSettings = async () => {
if (!selectedPlugin) return;
try {
await updatePluginSettings(selectedPlugin.id, pluginSettings, projectId);
addToast("Settings saved", "success");
} catch (err) {
addToast(`Failed to save settings: ${err instanceof Error ? err.message : String(err)}`, "error");
}
};
// Plugin detail view
if (selectedPlugin) {
return (
<div className="plugin-manager-detail" data-testid="plugin-manager-detail">
<div className="plugin-manager-detail-header">
<button className="btn-icon" onClick={() => setSelectedPlugin(null)} aria-label="Back to plugin list">
<X size={16} />
</button>
<div className="plugin-detail-title">
<h4 className="plugin-detail-name">{selectedPlugin.name}</h4>
<span className="plugin-state-badge" style={{ color: STATE_COLORS[selectedPlugin.state] || STATE_COLORS.installed }}>
{selectedPlugin.state}
</span>
</div>
</div>
<div className="plugin-detail-content">
<div className="plugin-detail-card">
{selectedPlugin.description && (
<p className="plugin-description">{selectedPlugin.description}</p>
)}
{selectedPlugin.author && (
<p className="plugin-detail-meta-row">
<span className="text-muted">Author:</span>
{selectedPlugin.author}
</p>
)}
{selectedPlugin.homepage && (
<p className="plugin-detail-meta-row plugin-homepage">
<span className="text-muted">Homepage:</span>
<a href={selectedPlugin.homepage} target="_blank" rel="noopener noreferrer">
{selectedPlugin.homepage}
<ExternalLink size={12} />
</a>
</p>
)}
<p className="plugin-detail-meta-row">
<span className="text-muted">Version:</span>
{selectedPlugin.version}
</p>
</div>
<div className="plugin-detail-card">
<h5 className="plugin-detail-section-heading">Settings</h5>
{settingsLoading ? (
<p className="text-muted">Loading...</p>
) : selectedPlugin.settingsSchema && Object.keys(selectedPlugin.settingsSchema).length > 0 ? (
<div className="plugin-settings-form">
{Object.entries(selectedPlugin.settingsSchema).map(([key, schema]) => {
const helpId = `setting-${key}-help`;
return (
<div key={key} className="form-group">
<label htmlFor={`setting-${key}`}>
{schema.label || key}
{schema.required && " *"}
</label>
{schema.type === "string" && !schema.multiline && (
<input
className="input"
type="text"
id={`setting-${key}`}
value={(pluginSettings[key] as string) ?? ""}
onChange={(e) => setPluginSettings({ ...pluginSettings, [key]: e.target.value })}
placeholder={schema.description}
aria-describedby={schema.description && !schema.required ? helpId : undefined}
/>
)}
{schema.type === "string" && schema.multiline && (
<textarea
className="input"
id={`setting-${key}`}
rows={4}
value={(pluginSettings[key] as string) ?? ""}
onChange={(e) => setPluginSettings({ ...pluginSettings, [key]: e.target.value })}
placeholder={schema.description}
aria-describedby={schema.description && !schema.required ? helpId : undefined}
/>
)}
{schema.type === "password" && (
<input
className="input"
type="password"
id={`setting-${key}`}
value={(pluginSettings[key] as string) ?? ""}
onChange={(e) => setPluginSettings({ ...pluginSettings, [key]: e.target.value })}
placeholder={schema.description}
aria-describedby={schema.description && !schema.required ? helpId : undefined}
/>
)}
{schema.type === "number" && (
<input
className="input"
type="number"
id={`setting-${key}`}
value={(pluginSettings[key] as number) ?? ""}
onChange={(e) => setPluginSettings({ ...pluginSettings, [key]: Number(e.target.value) })}
aria-describedby={schema.description && !schema.required ? helpId : undefined}
/>
)}
{schema.type === "boolean" && (
<label className="checkbox-label">
<input
type="checkbox"
checked={(pluginSettings[key] as boolean) ?? false}
onChange={(e) => setPluginSettings({ ...pluginSettings, [key]: e.target.checked })}
/>
{schema.description}
</label>
)}
{schema.type === "enum" && (
<select
className="select"
id={`setting-${key}`}
value={(pluginSettings[key] as string) ?? ""}
onChange={(e) => setPluginSettings({ ...pluginSettings, [key]: e.target.value })}
aria-describedby={schema.description && !schema.required ? helpId : undefined}
>
<option value="">Select...</option>
{schema.enumValues?.map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
)}
{schema.type === "array" && (
<div className="plugin-settings-array">
{(pluginSettings[key] as unknown[] | undefined)?.map((item, index) => (
<div key={index} className="plugin-settings-array-item">
<input
className="input"
type={schema.itemType === "number" ? "number" : "text"}
value={(item as string | number) ?? ""}
onChange={(e) => {
const newValue = e.target.value;
const current = (pluginSettings[key] as unknown[]) || [];
const updated = [...current];
updated[index] = schema.itemType === "number" ? Number(newValue) : newValue;
setPluginSettings({ ...pluginSettings, [key]: updated });
}}
/>
<button
className="btn-icon"
onClick={() => {
const current = (pluginSettings[key] as unknown[]) || [];
const updated = [...current];
updated.splice(index, 1);
setPluginSettings({ ...pluginSettings, [key]: updated });
}}
aria-label="Remove item"
>
<X size={14} />
</button>
</div>
))}
<button
className="btn btn-secondary"
onClick={() => {
const current = (pluginSettings[key] as unknown[]) || [];
const defaultItem = schema.itemType === "number" ? 0 : "";
setPluginSettings({ ...pluginSettings, [key]: [...current, defaultItem] });
}}
>
<Plus size={14} /> Add Item
</button>
</div>
)}
{schema.description && !schema.required && !schema.multiline && (
<span id={helpId} className="form-help">{schema.description}</span>
)}
</div>
);
})}
<button className="btn btn-primary" onClick={handleSaveSettings}>
Save Settings
</button>
</div>
) : (
<p className="text-muted">No configurable settings.</p>
)}
</div>
<div className="plugin-detail-actions">
{selectedPlugin.state === "started" && (
<button
className="btn btn-secondary"
onClick={() => handleReload(selectedPlugin)}
disabled={reloadingPluginId === selectedPlugin.id}
>
<RotateCcw size={14} className={reloadingPluginId === selectedPlugin.id ? "spin" : ""} />
{reloadingPluginId === selectedPlugin.id ? "Reloading..." : "Reload"}
</button>
)}
{selectedPlugin.enabled ? (
<button className="btn btn-secondary" onClick={() => handleDisable(selectedPlugin)}>
Disable
</button>
) : (
<button className="btn btn-primary" onClick={() => handleEnable(selectedPlugin)}>
Enable
</button>
)}
<button className="btn btn-danger" onClick={() => handleUninstall(selectedPlugin)}>
<Trash2 size={14} /> Uninstall
</button>
</div>
</div>
</div>
);
}
const installedPluginIds = new Set(plugins.map((plugin) => plugin.id));
const installedPluginsById = new Map(plugins.map((plugin) => [plugin.id, plugin]));
// Keep bundled plugins in the main list once installed so users can always
// access enable/disable, settings, and uninstall controls.
const installedPlugins = plugins;
const renderBundledRuntimeSection = () => (
<section className="plugin-bundled-runtime-section" aria-label="Bundled Plugins">
<div className="plugin-bundled-runtime-header">
<h4 className="plugin-bundled-runtime-heading">Bundled Plugins</h4>
<p className="plugin-bundled-runtime-description">
Install Fusion&apos;s bundled plugins directly from this screen.
</p>
</div>
<div className="plugin-bundled-runtime-list" aria-label="Bundled plugin recommendations">
{BUNDLED_PLUGINS.map((bundledPlugin) => {
const isInstalled = installedPluginIds.has(bundledPlugin.id);
return (
<div key={bundledPlugin.id} className="plugin-bundled-runtime-item">
<div className="plugin-bundled-runtime-meta">
<span className="plugin-bundled-runtime-name">{bundledPlugin.name}</span>
{bundledPlugin.experimental && (
<span className="plugin-bundled-runtime-badge">Experimental</span>
)}
<span
className={`plugin-bundled-runtime-status ${isInstalled ? "plugin-bundled-runtime-status--installed" : "plugin-bundled-runtime-status--available"}`}
>
{isInstalled ? "Installed" : "Not installed"}
</span>
</div>
<button
className={`btn ${isInstalled ? "btn-secondary" : "btn-primary"} btn-sm`}
onClick={() => {
if (isInstalled) {
const installedPlugin = installedPluginsById.get(bundledPlugin.id);
if (installedPlugin) {
void handleSelectPlugin(installedPlugin);
}
return;
}
void handleInstallBundledPlugin(bundledPlugin);
}}
disabled={installingBundledPluginId === bundledPlugin.id}
>
{isInstalled
? "Manage"
: installingBundledPluginId === bundledPlugin.id
? "Installing..."
: `Install ${bundledPlugin.name}`}
</button>
</div>
);
})}
</div>
</section>
);
// Plugin list view
return (
<div className="plugin-manager" data-testid="plugin-manager">
<div className="plugin-manager-header">
<span className="plugin-manager-header-title">Installed Plugins</span>
<div className="plugin-manager-actions">
<button className="btn btn-sm" onClick={loadPlugins} title="Refresh" aria-label="Refresh plugin list">
<RefreshCw size={14} className={loading ? "spin" : ""} />
Refresh
</button>
<button className="btn btn-primary btn-sm" onClick={() => setShowInstall(true)}>
<Plus size={14} /> Install
</button>
</div>
</div>
{showInstall && (
<div className="plugin-install-form">
<p className="plugin-install-hint">
Browse to a plugin package root (contains <code>manifest.json</code>) or a built <code>dist</code> directory.
</p>
<DirectoryPicker
value={installPath}
onChange={setInstallPath}
placeholder="Absolute path to plugin directory or dist folder"
onInputKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleInstall();
}
}}
/>
<div className="plugin-install-actions">
<button className="btn btn-primary" onClick={handleInstall} disabled={installing || !installPath.trim()}>
{installing ? "Installing..." : "Install Plugin"}
</button>
<button className="btn btn-secondary" onClick={() => { setShowInstall(false); setInstallPath(""); }}>
Cancel
</button>
</div>
</div>
)}
{loading ? (
<div className="settings-empty-state">Loading plugins...</div>
) : (
<>
{installedPlugins.length === 0 ? (
<div className="settings-empty-state">
<Package size={32} className="text-muted" />
<p>No plugins installed.</p>
<p className="text-muted">Install a plugin to get started, or use a bundled plugin below.</p>
</div>
) : (
<div className="plugin-list">
{installedPlugins.map((plugin) => (
<div key={plugin.id} className="plugin-item">
<div className="plugin-info">
<span className="plugin-name">{plugin.name}</span>
<span className="plugin-version text-muted">v{plugin.version}</span>
<span className="plugin-state-badge" style={{ color: STATE_COLORS[plugin.state] || STATE_COLORS.installed }}>
{plugin.state}
</span>
</div>
<div className="plugin-actions">
{plugin.state === "started" && (
<button
className="btn-icon"
onClick={() => handleReload(plugin)}
disabled={reloadingPluginId === plugin.id}
title="Reload"
>
<RotateCcw size={14} className={reloadingPluginId === plugin.id ? "spin" : ""} />
</button>
)}
<label className="toggle-switch">
<input
type="checkbox"
checked={plugin.enabled}
onChange={() => plugin.enabled ? handleDisable(plugin) : handleEnable(plugin)}
/>
<span className="toggle-slider"></span>
</label>
<button
className="btn-icon"
onClick={() => handleSelectPlugin(plugin)}
title="Settings"
>
<Settings size={14} />
</button>
<button
className="btn-icon"
onClick={() => handleUninstall(plugin)}
title="Uninstall"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
)}
{renderBundledRuntimeSection()}
</>
)}
</div>
);
}