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:
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user