fix(FN-2492): polish plugin manager bundled runtime UX

- Add a Bundled Runtime Plugins section with one-click install actions, install-state badges, and empty-state discoverability improvements
- Refine plugin detail semantics and controls by tightening heading hierarchy, button variants, and mobile/toggle styling behavior
- Expand PluginManager test coverage for bundled runtime installation, installed-state disabling, and heading-level accessibility contracts
- Clean up dashboard CSS ownership by removing duplicated global/toggle rules, updating surface-hover usage, and documenting bundled runtime discovery in settings docs
This commit is contained in:
Fusion
2026-04-25 13:46:00 -07:00
committed by gsxdsm
parent 6461108a05
commit c597dd4055
10 changed files with 360 additions and 192 deletions

View File

@@ -101,9 +101,8 @@ describe("Status color CSS custom properties", () => {
expect(css).not.toMatch(/--surface-(subtle|muted|emphasis|hover-strong):\s*rgba\(/);
});
it("uses --surface-hover without per-rule fallback overrides", () => {
it("uses --surface-hover token references without raw rgba fallbacks", () => {
expect(css).toContain("var(--surface-hover)");
expect(css).not.toContain("var(--surface-hover,");
expect(css).not.toMatch(/var\(--surface-hover,\s*rgba\(/);
});

View File

@@ -439,69 +439,6 @@
}
/* Plugin toggle switch */
.toggle-switch {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: calc(var(--space-xl) + var(--space-lg) + var(--space-xs));
min-height: calc(var(--space-xl) + var(--space-lg) + var(--space-xs));
cursor: pointer;
}
.toggle-switch input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.toggle-switch .toggle-slider {
width: calc(var(--space-lg) * 2 + var(--space-sm));
height: calc(var(--space-md) * 2);
background: var(--border);
border-radius: var(--radius-pill);
transition: background var(--transition-normal);
position: relative;
}
.toggle-switch .toggle-slider::after {
content: "";
position: absolute;
top: var(--space-xs);
left: var(--space-xs);
width: calc(var(--space-md) + var(--space-xs));
height: calc(var(--space-md) + var(--space-xs));
background: var(--card);
border-radius: var(--radius-pill);
transition: transform var(--transition-normal);
}
.toggle-switch input:focus-visible + .toggle-slider {
box-shadow: var(--focus-ring-strong);
}
.toggle-switch input:checked + .toggle-slider {
background: var(--in-review);
}
.toggle-switch input:checked + .toggle-slider::after {
transform: translateX(calc(var(--space-md) + var(--space-xs)));
}
.toggle-switch input:disabled + .toggle-slider {
opacity: 0.6;
cursor: not-allowed;
}
/* Mobile responsive for file mention popup */
@media (max-width: 768px) {

View File

@@ -14,8 +14,11 @@
border-bottom: 1px solid var(--border);
}
.pi-ext-manager-header .settings-section-heading {
.pi-ext-manager-title {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
}
.pi-ext-manager-actions {

View File

@@ -236,7 +236,7 @@ export function PiExtensionsManager({ addToast, projectId }: PiExtensionsManager
return (
<div className="pi-ext-manager">
<div className="pi-ext-manager-header">
<h4 className="settings-section-heading">Pi Extensions</h4>
<h4 className="pi-ext-manager-title">Pi Extensions</h4>
<div className="pi-ext-manager-actions">
<button className="btn-icon" onClick={loadSettings} title="Refresh" disabled={loading}>
<RefreshCw size={16} className={loading ? "spin" : ""} />

View File

@@ -12,7 +12,7 @@
align-items: center;
justify-content: space-between;
padding-bottom: var(--space-sm);
border-bottom: 1px solid var(--border);
border-bottom: var(--btn-border-width) solid var(--border);
}
.plugin-manager-actions {
@@ -26,7 +26,7 @@
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-lg);
border: 1px solid var(--border);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
@@ -39,7 +39,7 @@
}
.plugin-install-hint code {
padding: 1px var(--space-xs);
padding: var(--btn-border-width) var(--space-xs);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
font-size: 0.85em;
@@ -63,7 +63,7 @@
justify-content: space-between;
padding: var(--space-md) var(--space-lg);
background: var(--surface);
border: 1px solid var(--border);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
transition: border-color var(--transition-fast);
}
@@ -109,6 +109,7 @@
flex-shrink: 0;
}
/* Detail view */
.plugin-manager-detail-header {
@@ -125,7 +126,7 @@
flex-wrap: wrap;
}
.plugin-detail-title h3 {
.plugin-detail-name {
margin: 0;
}
@@ -137,7 +138,7 @@
.plugin-detail-card {
background: var(--surface);
border: 1px solid var(--border);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-lg);
display: flex;
@@ -167,9 +168,11 @@
font-size: 0.85rem;
}
/* Plugin detail headings don't need extra margin (they're inside a tight card layout) */
.plugin-settings-form .settings-section-heading {
.plugin-detail-section-heading {
margin: 0;
padding: 0;
border: 0;
font-size: 0.95rem;
}
.plugin-settings-form {
@@ -181,7 +184,7 @@
.plugin-settings-form .form-group {
padding: 0;
margin-bottom: 0;
margin: 0;
}
.plugin-settings-array {
@@ -204,7 +207,7 @@
display: flex;
gap: var(--space-sm);
padding-top: var(--space-md);
border-top: 1px solid var(--border);
border-top: var(--btn-border-width) solid var(--border);
justify-content: flex-end;
}
@@ -222,6 +225,93 @@
color: var(--text-muted);
}
.plugin-bundled-runtime-list {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.plugin-bundled-runtime-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.plugin-bundled-runtime-meta {
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 0;
}
.plugin-bundled-runtime-name {
color: var(--text);
font-size: 0.9rem;
font-weight: 500;
}
.plugin-bundled-runtime-badge {
display: inline-flex;
align-items: center;
padding: var(--btn-border-width) var(--space-xs);
border-radius: var(--radius-pill);
background: var(--status-in-review-bg);
color: var(--in-review);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.plugin-bundled-runtime-section {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.plugin-bundled-runtime-header {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.plugin-bundled-runtime-heading {
margin: 0;
font-size: 0.95rem;
}
.plugin-bundled-runtime-description {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.plugin-bundled-runtime-status {
display: inline-flex;
align-items: center;
padding: var(--btn-border-width) var(--space-xs);
border-radius: var(--radius-pill);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.plugin-bundled-runtime-status--installed {
background: var(--status-done-bg);
color: var(--done);
}
.plugin-bundled-runtime-status--available {
background: var(--status-todo-bg);
color: var(--todo);
}
@media (max-width: 768px) {
.plugin-manager-detail-header {
gap: var(--space-sm);
@@ -242,19 +332,57 @@
.plugin-item {
padding: var(--space-md);
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
}
.plugin-info {
width: 100%;
flex-wrap: wrap;
row-gap: var(--space-xs);
}
.plugin-name {
flex: 1 1 100%;
white-space: normal;
}
.plugin-actions {
gap: var(--space-xs);
width: 100%;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-sm);
}
.plugin-actions .btn-icon,
.plugin-actions .toggle-switch {
min-width: 36px;
min-height: 36px;
}
.plugin-actions .toggle-switch {
display: inline-flex;
align-items: center;
justify-content: center;
}
.plugin-detail-actions {
flex-wrap: wrap;
justify-content: stretch;
justify-content: flex-start;
}
.plugin-detail-actions button {
flex: 1 1 auto;
min-height: 36px;
}
.plugin-bundled-runtime-item {
flex-direction: column;
align-items: stretch;
}
.plugin-bundled-runtime-item .btn {
min-height: 36px;
}
}

View File

@@ -38,6 +38,33 @@ interface PluginManagerProps {
projectId?: string;
}
interface BundledRuntimePlugin {
id: string;
name: string;
path: string;
experimental?: boolean;
}
const BUNDLED_RUNTIME_PLUGINS: BundledRuntimePlugin[] = [
{
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,
},
];
export const STATE_COLORS: Record<string, string> = {
started: "var(--color-success)",
loaded: "var(--color-warning)",
@@ -56,6 +83,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
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 loadPlugins = useCallback(async () => {
try {
@@ -173,6 +201,19 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
}
};
const handleInstallBundledRuntimePlugin = async (plugin: BundledRuntimePlugin) => {
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);
@@ -254,7 +295,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
<X size={16} />
</button>
<div className="plugin-detail-title">
<h3>{selectedPlugin.name}</h3>
<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>
@@ -288,7 +329,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
</div>
<div className="plugin-detail-card">
<h4 className="settings-section-heading">Settings</h4>
<h5 className="plugin-detail-section-heading">Settings</h5>
{settingsLoading ? (
<p className="text-muted">Loading...</p>
) : selectedPlugin.settingsSchema && Object.keys(selectedPlugin.settingsSchema).length > 0 ? (
@@ -439,7 +480,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
Enable
</button>
)}
<button className="btn-danger" onClick={() => handleUninstall(selectedPlugin)}>
<button className="btn btn-danger" onClick={() => handleUninstall(selectedPlugin)}>
<Trash2 size={14} /> Uninstall
</button>
</div>
@@ -448,6 +489,50 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
);
}
const installedPluginIds = new Set(plugins.map((plugin) => plugin.id));
const renderBundledRuntimeSection = () => (
<section className="plugin-bundled-runtime-section" aria-label="Bundled Runtime Plugins">
<div className="plugin-bundled-runtime-header">
<h4 className="plugin-bundled-runtime-heading">Bundled Runtime Plugins</h4>
<p className="plugin-bundled-runtime-description">
Install Fusion&apos;s bundled runtimes directly from this screen.
</p>
</div>
<div className="plugin-bundled-runtime-list" aria-label="Bundled runtime plugin recommendations">
{BUNDLED_RUNTIME_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={() => handleInstallBundledRuntimePlugin(bundledPlugin)}
disabled={isInstalled || installingBundledPluginId === bundledPlugin.id}
>
{isInstalled
? "Installed"
: installingBundledPluginId === bundledPlugin.id
? "Installing..."
: `Install ${bundledPlugin.name}`}
</button>
</div>
);
})}
</div>
</section>
);
// Plugin list view
return (
<div className="plugin-manager" data-testid="plugin-manager">
@@ -491,60 +576,65 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
{loading ? (
<div className="settings-empty-state">Loading plugins...</div>
) : plugins.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.</p>
</div>
) : (
<div className="plugin-list">
{plugins.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>
<>
{plugins.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 runtime below.</p>
</div>
))}
</div>
) : (
<div className="plugin-list">
{plugins.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>
);

View File

@@ -194,7 +194,7 @@
}
.settings-plugins-subsection-btn:hover {
background: var(--surface-hover);
background: var(--surface-hover, color-mix(in srgb, var(--text-muted) 8%, transparent));
color: var(--text);
}

View File

@@ -191,7 +191,28 @@ describe("PluginManager", () => {
});
expect(screen.getByText("No plugins installed.")).toBeTruthy();
expect(screen.getByRole("button", { name: /^Install$/ })).toBeTruthy();
expect(screen.getByText("Hermes Runtime")).toBeTruthy();
expect(screen.getByText("Paperclip Runtime")).toBeTruthy();
expect(screen.getByText("OpenClaw Runtime")).toBeTruthy();
});
it("installs bundled runtime plugins from the bundled runtime section", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(fetchPlugins).toHaveBeenCalled();
});
const hermesCard = screen.getByText("Hermes Runtime").closest(".plugin-bundled-runtime-item");
expect(hermesCard).toBeTruthy();
const installButton = within(hermesCard as HTMLElement).getByRole("button", { name: /Install Hermes Runtime/i });
await userEvent.click(installButton);
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith({ path: "./plugins/fusion-plugin-hermes-runtime" }, undefined);
expect(addToast).toHaveBeenCalledWith("Hermes Runtime installed successfully", "success");
});
});
it("renders plugin list when plugins are available", async () => {
@@ -206,6 +227,8 @@ describe("PluginManager", () => {
expect(screen.getByText("Test Plugin B")).toBeTruthy();
expect(screen.getByText("v1.0.0")).toBeTruthy();
expect(screen.getByText("v2.0.0")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Bundled Runtime Plugins" })).toBeTruthy();
expect(screen.getAllByText("Not installed").length).toBeGreaterThan(0);
});
it("shows install form with directory picker and hint when Install button is clicked", async () => {
@@ -441,6 +464,29 @@ describe("PluginManager", () => {
});
});
it("shows installed status and disables bundled runtime install button when already installed", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce([
{
...mockPlugins[0],
id: "fusion-plugin-hermes-runtime",
name: "Hermes Runtime Plugin",
},
]);
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Hermes Runtime")).toBeTruthy();
});
const hermesCard = screen.getByText("Hermes Runtime").closest(".plugin-bundled-runtime-item");
expect(hermesCard).toBeTruthy();
const installButton = within(hermesCard as HTMLElement).getByRole("button", { name: /^Installed$/i });
expect(installButton).toBeDisabled();
expect(within(hermesCard as HTMLElement).getAllByText("Installed").length).toBeGreaterThanOrEqual(1);
});
describe("SSE Live Updates", () => {
it("subscribes to plugin:lifecycle SSE events", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
@@ -989,5 +1035,24 @@ describe("PluginManager", () => {
// But should not contain an h3 (heading is provided by SettingsModal's settings-section-heading)
expect(header?.querySelector("h3")).toBeNull();
});
it("keeps plugin detail heading hierarchy with plugin title above settings section", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Test Plugin A")).toBeTruthy();
});
const settingsButtons = screen.getAllByTitle("Settings");
await userEvent.click(settingsButtons[0]);
const pluginTitle = await screen.findByRole("heading", { name: "Test Plugin A", level: 4 });
const settingsHeading = screen.getByRole("heading", { name: "Settings", level: 5 });
expect(pluginTitle).toBeTruthy();
expect(settingsHeading).toBeTruthy();
});
});
});

View File

@@ -1100,51 +1100,6 @@ input[type="range"]:focus-visible {
margin-top: var(--space-xl);
}
.settings-plugins-subsection-toggle {
display: inline-flex;
gap: var(--space-xs);
padding: 0 var(--space-xl);
margin: var(--space-md) 0;
}
.settings-plugins-subsection-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-pill);
background: var(--surface);
color: var(--text-muted);
font-size: 0.85rem;
font-weight: 600;
padding: var(--space-xs) var(--space-md);
cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.settings-plugins-subsection-btn:hover {
background: var(--surface-hover);
color: var(--text);
}
.settings-plugins-subsection-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.settings-plugins-subsection-btn.active {
background: color-mix(in srgb, var(--todo) 14%, transparent);
border-color: color-mix(in srgb, var(--todo) 45%, var(--border));
color: var(--todo);
}
.settings-plugins-subsection-panel {
padding-bottom: var(--space-md);
}
/* Scope indicators in sidebar nav items */
.settings-scope-icon {
@@ -2950,17 +2905,6 @@ input[type="range"]:focus-visible {
margin: 0 var(--space-lg) 0;
}
.settings-plugins-subsection-toggle {
display: flex;
width: 100%;
padding: 0 var(--space-lg);
}
.settings-plugins-subsection-btn {
flex: 1;
min-height: 36px;
}
.form-group {
padding: 0 14px;
}