feat(FN-3576): restore plugin setting group field and grouped agent browser

This merge restores plugin setting group functionality in the dashboard (FN-3576), adds documentation for plugin authoring, and improves test isolation by broadening runtime ignore lists for live fusion app paths in the isolation checker script. The feature touches the PluginManager component with n

Fusion-Task-Id: FN-3576
This commit is contained in:
Fusion
2026-05-06 14:47:13 -07:00
committed by gsxdsm
parent a1ec43d580
commit 2858a0b0b5
6 changed files with 202 additions and 8 deletions

View File

@@ -149,6 +149,13 @@ const settingsSchema: Record<string, PluginSettingSchema> = {
### Setting Types
Common optional fields on all setting types:
- `group?: string` — Optional heading used by the dashboard to render settings in grouped sections (for example: `"General"`, `"Browser"`, `"Prompt Contributions"`, `"Skills"`).
- `description?: string` — Helper text shown below the setting label.
- `required?: boolean` — Marks the field as required.
- `defaultValue?: unknown` — Default value used when no user value is provided.
| Type | Description | Extra Fields |
|------|-------------|--------------|
| `"string"` | Text input | `multiline?: boolean` (renders textarea) |
@@ -196,6 +203,7 @@ const settingsSchema: Record<string, PluginSettingSchema> = {
enabled: {
type: "boolean",
label: "Enable Feature",
group: "General",
defaultValue: true,
},
@@ -212,6 +220,7 @@ const settingsSchema: Record<string, PluginSettingSchema> = {
type: "array",
label: "Tags",
description: "Tags to track",
group: "Skills",
itemType: "string",
defaultValue: ["bug", "feature"],
},

View File

@@ -148,6 +148,21 @@ describe("validatePluginManifest", () => {
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it("accepts settings schema entries with optional group metadata", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
settingsSchema: {
enabled: { type: "boolean", group: "General" },
timeoutMs: { type: "number", group: "Browser" },
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
});
// ── Missing Required Fields ─────────────────────────────────────────

View File

@@ -73,6 +73,8 @@ export interface PluginSettingSchema {
enumValues?: string[];
/** Only when type is "string" - renders as textarea when true */
multiline?: boolean;
/** Optional UI grouping label used by settings forms */
group?: string;
/** Only when type is "array" - type of items in the array */
itemType?: "string" | "number";
}

View File

@@ -204,6 +204,21 @@
margin: 0;
}
.plugin-settings-group {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.plugin-settings-group-heading {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
}
.plugin-settings-array {
display: flex;
flex-direction: column;

View File

@@ -15,7 +15,7 @@ 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 { PluginInstallation, PluginState, PluginSettingSchema } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm";
import { subscribeSse } from "../sse-bus";
@@ -46,9 +46,42 @@ interface BundledPlugin {
experimental?: boolean;
}
export const DEFAULT_AGENT_BROWSER_PLUGIN_ID = "fusion-plugin-agent-browser-runtime";
export const AGENT_BROWSER_SETTINGS_SCHEMA: Record<string, PluginSettingSchema> = {
enabled: { type: "boolean", label: "Enable Agent Browser", group: "General" },
installChannel: {
type: "enum",
label: "Install Channel",
enumValues: ["stable", "beta", "nightly"],
defaultValue: "stable",
group: "General",
},
commandTimeoutMs: {
type: "number",
label: "Command Timeout (ms)",
defaultValue: 120000,
group: "General",
},
headlessMode: { type: "boolean", label: "Headless Mode", defaultValue: true, group: "Browser" },
allowedDomains: { type: "array", label: "Allowed Domains", itemType: "string", group: "Browser" },
promptExecutorSystem: { type: "string", label: "Executor System Prompt", multiline: true, group: "Prompt Contributions" },
promptExecutorTask: { type: "string", label: "Executor Task Prompt", multiline: true, group: "Prompt Contributions" },
promptTriage: { type: "string", label: "Triage Prompt", multiline: true, group: "Prompt Contributions" },
promptReviewer: { type: "string", label: "Reviewer Prompt", multiline: true, group: "Prompt Contributions" },
promptHeartbeat: { type: "string", label: "Heartbeat Prompt", multiline: true, group: "Prompt Contributions" },
skillExposure: {
type: "enum",
label: "Skill Exposure",
enumValues: ["none", "selected", "all"],
defaultValue: "selected",
group: "Skills",
},
};
const BUNDLED_PLUGINS: BundledPlugin[] = [
{
id: "fusion-plugin-agent-browser-runtime",
id: DEFAULT_AGENT_BROWSER_PLUGIN_ID,
name: "Agent Browser Runtime",
path: "./plugins/fusion-plugin-agent-browser-runtime",
experimental: true,
@@ -91,6 +124,25 @@ export const STATE_COLORS: Record<string, string> = {
installed: "var(--color-info)",
};
function groupSettingsSchema(settingsSchema: Record<string, PluginSettingSchema>) {
const grouped = new Map<string, Array<[string, PluginSettingSchema]>>();
const ungrouped: Array<[string, PluginSettingSchema]> = [];
for (const [key, schema] of Object.entries(settingsSchema)) {
if (schema.group) {
const groupItems = grouped.get(schema.group) ?? [];
groupItems.push([key, schema]);
grouped.set(schema.group, groupItems);
} else {
ungrouped.push([key, schema]);
}
}
return { grouped, ungrouped };
}
export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const [plugins, setPlugins] = useState<PluginInstallation[]>([]);
const [loading, setLoading] = useState(true);
@@ -358,9 +410,29 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
<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 (
{(() => {
const { grouped, ungrouped } = groupSettingsSchema(selectedPlugin.settingsSchema);
const sections: Array<{ title: string | null; entries: Array<[string, PluginSettingSchema]> }> = [];
if (ungrouped.length > 0) {
sections.push({ title: null, entries: ungrouped });
}
for (const [groupName, entries] of grouped.entries()) {
sections.push({ title: groupName, entries });
}
return sections.map((section) => (
<div
key={section.title ?? "ungrouped"}
className={section.title ? "plugin-settings-group" : undefined}
>
{section.title && (
<h6 className="plugin-settings-group-heading">{section.title}</h6>
)}
{section.entries.map(([key, schema]) => {
const helpId = `setting-${key}-help`;
return (
<div key={key} className="form-group">
<label htmlFor={`setting-${key}`}>
{schema.label || key}
@@ -479,8 +551,11 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
<span id={helpId} className="form-help">{schema.description}</span>
)}
</div>
);
})}
);
})}
</div>
));
})()}
<button className="btn btn-primary" onClick={handleSaveSettings}>
Save Settings
</button>

View File

@@ -105,7 +105,12 @@ vi.mock("../../hooks/useConfirm", () => ({
}));
// Import after vi.mock so the mock is in place
import { PluginManager, STATE_COLORS } from "../PluginManager";
import {
AGENT_BROWSER_SETTINGS_SCHEMA,
DEFAULT_AGENT_BROWSER_PLUGIN_ID,
PluginManager,
STATE_COLORS,
} from "../PluginManager";
import {
fetchPlugins,
installPlugin,
@@ -218,6 +223,24 @@ describe("PluginManager", () => {
expect(screen.getByText("Loading plugins...")).toBeTruthy();
});
it("exports AGENT_BROWSER_SETTINGS_SCHEMA with expected grouped keys", () => {
expect(Object.keys(AGENT_BROWSER_SETTINGS_SCHEMA)).toEqual([
"enabled",
"installChannel",
"commandTimeoutMs",
"headlessMode",
"allowedDomains",
"promptExecutorSystem",
"promptExecutorTask",
"promptTriage",
"promptReviewer",
"promptHeartbeat",
"skillExposure",
]);
expect(AGENT_BROWSER_SETTINGS_SCHEMA.promptExecutorSystem?.group).toBe("Prompt Contributions");
expect(AGENT_BROWSER_SETTINGS_SCHEMA.skillExposure?.group).toBe("Skills");
});
it("renders empty state when no plugins are installed", async () => {
render(<PluginManager addToast={addToast} />);
@@ -527,6 +550,32 @@ describe("PluginManager", () => {
});
});
it("shows Manage for bundled agent browser runtime when already installed", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce([
{
...mockPlugins[0],
id: DEFAULT_AGENT_BROWSER_PLUGIN_ID,
name: "Agent Browser Runtime",
},
]);
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getAllByText("Agent Browser Runtime").length).toBeGreaterThanOrEqual(2);
});
const runtimeCard = screen.getAllByText("Agent Browser Runtime")[1]?.closest(".plugin-bundled-runtime-item");
expect(runtimeCard).toBeTruthy();
const manageButton = within(runtimeCard as HTMLElement).getByRole("button", { name: /^Manage$/i });
await userEvent.click(manageButton);
await waitFor(() => {
expect(fetchPluginSettings).toHaveBeenCalledWith(DEFAULT_AGENT_BROWSER_PLUGIN_ID, undefined);
});
});
it("shows Manage for bundled dependency graph when already installed", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce([
{
@@ -784,6 +833,35 @@ describe("PluginManager", () => {
});
});
describe("grouped settings rendering", () => {
it("renders grouped headings and preserves ungrouped settings", async () => {
const groupedPlugin: PluginInstallation = {
...mockPlugins[0],
id: "plugin-grouped",
name: "Grouped Plugin",
settingsSchema: {
enabled: { type: "boolean", label: "Enabled", group: "General" },
prompt: { type: "string", label: "Prompt", multiline: true, group: "Prompt Contributions" },
legacySetting: { type: "string", label: "Legacy Setting" },
},
};
vi.mocked(fetchPlugins).mockResolvedValueOnce([groupedPlugin]);
vi.mocked(fetchPluginSettings).mockResolvedValueOnce({ enabled: true, prompt: "x", legacySetting: "y" });
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Grouped Plugin")).toBeTruthy();
});
await userEvent.click(screen.getAllByTitle("Settings")[0]);
expect(await screen.findByRole("heading", { name: "General", level: 6 })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Prompt Contributions", level: 6 })).toBeTruthy();
expect(screen.getByLabelText("Legacy Setting")).toBeTruthy();
});
});
describe("new input types", () => {
const mockPluginWithNewTypes: PluginInstallation = {
id: "plugin-new-types",