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:
@@ -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\(/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" : ""} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user