feat(FN-2443): unify plugin settings into tabbed Plugins section

- Remove the standalone Pi Extensions section from settings navigation and route legacy initialSection values to Plugins
- Add accessible subsection tabs in Plugins for Fusion Plugins and Pi Extensions with proper tab/tabpanel semantics
- Render plugin content conditionally per active subsection so only the selected manager is mounted
- Add SettingsModal tests covering sidebar navigation, tab accessibility, and subsection switching behavior
- Add subsection styling in SettingsModal.css and preserve extracted styles.css boundaries
This commit is contained in:
Fusion
2026-04-25 10:35:27 -07:00
committed by gsxdsm
parent 147419022e
commit fcbd650a6b
3 changed files with 197 additions and 15 deletions

View File

@@ -150,6 +150,52 @@
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 {
margin-right: 6px;
@@ -676,6 +722,17 @@
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;
}

View File

@@ -29,7 +29,7 @@ import "./SettingsModal.css";
*
* Group headers (isGroupHeader: true) are non-clickable labels that visually group sections.
* The sidebar is organized into three groups:
* - Account: Scope-less sections (authentication, pi-extensions)
* - Account: Scope-less sections (authentication)
* - Global: Global-scoped sections (appearance, notifications, node-sync, global-models)
* - Project: Project-scoped sections (project-models, general, scheduling, worktrees, commands,
* merge, memory, experimental, prompts, backups, plugins)
@@ -54,7 +54,6 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
// Account group (scope-less items — independent of settings storage)
{ id: "__account_header", label: "Account", scope: undefined, isGroupHeader: true },
{ id: "authentication", label: "Authentication", scope: undefined, icon: Globe },
{ id: "pi-extensions", label: "Pi Extensions", scope: undefined },
// Global group (shared across all Fusion projects)
{ id: "__global_header", label: "Global", scope: undefined, isGroupHeader: true },
@@ -102,7 +101,10 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
devServerView: "Dev Server View",
};
export type SectionId = SettingsSection["id"];
type LegacySectionId = "pi-extensions";
export type SectionId = SettingsSection["id"] | LegacySectionId;
type PluginsSubsectionId = "fusion-plugins" | "pi-extensions";
/** Local form state extends Settings with a worktreeInitCommand override and lets tokenCap carry null (delete semantic). */
type SettingsFormState = Settings & { worktreeInitCommand?: string; tokenCap?: number | null };
@@ -162,7 +164,17 @@ export function SettingsModal({
const [initialScopedValues, setInitialScopedValues] = useState<{ global: GlobalSettings; project: Partial<Settings> } | null>(null);
// Find the first non-group-header section for default active section
const firstNonHeaderSection = SETTINGS_SECTIONS.find((s) => !s.isGroupHeader);
const [activeSection, setActiveSection] = useState<SectionId>(initialSection ?? firstNonHeaderSection?.id ?? "authentication");
const [activeSection, setActiveSection] = useState<SectionId>(() => {
if (initialSection === "pi-extensions") {
return "plugins";
}
return initialSection ?? firstNonHeaderSection?.id ?? "authentication";
});
// Deterministic default: opening Plugins starts on Fusion Plugins unless legacy
// `initialSection="pi-extensions"` is explicitly provided.
const [activePluginsSubsection, setActivePluginsSubsection] = useState<PluginsSubsectionId>(() =>
initialSection === "pi-extensions" ? "pi-extensions" : "fusion-plugins",
);
const [showMobileSectionPicker, setShowMobileSectionPicker] = useState(() =>
typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia(MOBILE_SETTINGS_MEDIA_QUERY)?.matches === true
@@ -3110,18 +3122,63 @@ export function SettingsModal({
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">Plugins</h4>
<Suspense fallback={null}>
<PluginManager addToast={addToast} projectId={projectId} />
</Suspense>
<PluginSlot slotId="settings-section" projectId={projectId} />
<div className="settings-plugins-subsection-toggle" role="tablist" aria-label="Plugin manager type">
<button
type="button"
id="plugins-tab-fusion-plugins"
role="tab"
aria-controls="plugins-panel-fusion-plugins"
aria-selected={activePluginsSubsection === "fusion-plugins"}
tabIndex={activePluginsSubsection === "fusion-plugins" ? 0 : -1}
className={`settings-plugins-subsection-btn${activePluginsSubsection === "fusion-plugins" ? " active" : ""}`}
onClick={() => setActivePluginsSubsection("fusion-plugins")}
>
Fusion Plugins
</button>
<button
type="button"
id="plugins-tab-pi-extensions"
role="tab"
aria-controls="plugins-panel-pi-extensions"
aria-selected={activePluginsSubsection === "pi-extensions"}
tabIndex={activePluginsSubsection === "pi-extensions" ? 0 : -1}
className={`settings-plugins-subsection-btn${activePluginsSubsection === "pi-extensions" ? " active" : ""}`}
onClick={() => setActivePluginsSubsection("pi-extensions")}
>
Pi Extensions
</button>
</div>
<div
id="plugins-panel-fusion-plugins"
role="tabpanel"
aria-labelledby="plugins-tab-fusion-plugins"
className="settings-plugins-subsection-panel"
hidden={activePluginsSubsection !== "fusion-plugins"}
>
{activePluginsSubsection === "fusion-plugins" && (
<>
<Suspense fallback={null}>
<PluginManager addToast={addToast} projectId={projectId} />
</Suspense>
<PluginSlot slotId="settings-section" projectId={projectId} />
</>
)}
</div>
<div
id="plugins-panel-pi-extensions"
role="tabpanel"
aria-labelledby="plugins-tab-pi-extensions"
className="settings-plugins-subsection-panel"
hidden={activePluginsSubsection !== "pi-extensions"}
>
{activePluginsSubsection === "pi-extensions" && (
<Suspense fallback={null}>
<PiExtensionsManager addToast={addToast} projectId={projectId} />
</Suspense>
)}
</div>
</>
);
case "pi-extensions":
return (
<Suspense fallback={null}>
<PiExtensionsManager addToast={addToast} projectId={projectId} />
</Suspense>
);
case "authentication": {
// CLI-backed providers (currently just claude-cli) render their own
// compact card with Enable/Disable + Test actions — bypassing the

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SettingsModal } from "../SettingsModal";
import type { SettingsExportData } from "../../api";
@@ -61,6 +61,18 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
}));
vi.mock("../PluginManager", () => ({
PluginManager: () => <div data-testid="plugin-manager">Plugin manager content</div>,
}));
vi.mock("../PiExtensionsManager", () => ({
PiExtensionsManager: () => <div data-testid="pi-extensions-manager">Pi extensions content</div>,
}));
vi.mock("../PluginSlot", () => ({
PluginSlot: () => <div data-testid="plugin-slot">Plugin slot content</div>,
}));
const noop = () => {};
const defaultSettings = {
@@ -90,6 +102,13 @@ function renderModal(props = {}) {
);
}
async function waitForSettingsModalReady() {
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
});
}
describe("SettingsModal", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -318,6 +337,55 @@ describe("SettingsModal", () => {
});
});
describe("Plugins section navigation", () => {
it("does not render a standalone Pi Extensions sidebar item", async () => {
renderModal();
await waitForSettingsModalReady();
const sidebar = document.querySelector(".settings-sidebar");
expect(sidebar).toBeInTheDocument();
expect(within(sidebar as HTMLElement).queryByRole("button", { name: /Pi Extensions$/ })).not.toBeInTheDocument();
expect(within(sidebar as HTMLElement).getByRole("button", { name: /Plugins$/ })).toBeInTheDocument();
});
it("renders accessible tab semantics for Plugins subsection controls", async () => {
renderModal();
await waitForSettingsModalReady();
await userEvent.click(await screen.findByRole("button", { name: /Plugins$/ }));
const tablist = await screen.findByRole("tablist", { name: "Plugin manager type" });
const fusionTab = within(tablist).getByRole("tab", { name: "Fusion Plugins" });
const piTab = within(tablist).getByRole("tab", { name: "Pi Extensions" });
expect(fusionTab).toHaveAttribute("aria-selected", "true");
expect(fusionTab).toHaveAttribute("aria-controls", "plugins-panel-fusion-plugins");
expect(piTab).toHaveAttribute("aria-selected", "false");
expect(piTab).toHaveAttribute("aria-controls", "plugins-panel-pi-extensions");
});
it("switches between Fusion Plugins and Pi Extensions managers", async () => {
renderModal();
await waitForSettingsModalReady();
await userEvent.click(await screen.findByRole("button", { name: /Plugins$/ }));
expect(await screen.findByTestId("plugin-manager")).toBeInTheDocument();
expect(screen.getByTestId("plugin-slot")).toBeInTheDocument();
expect(screen.queryByTestId("pi-extensions-manager")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: "Pi Extensions" }));
await waitFor(() => {
expect(screen.getByTestId("pi-extensions-manager")).toBeInTheDocument();
});
expect(screen.queryByTestId("plugin-manager")).not.toBeInTheDocument();
expect(screen.queryByTestId("plugin-slot")).not.toBeInTheDocument();
expect(screen.getByRole("tabpanel", { name: "Pi Extensions" })).toBeVisible();
expect(document.getElementById("plugins-panel-fusion-plugins")).toHaveAttribute("hidden");
});
});
describe("Number input clearing", () => {
it("allows clearing maxConcurrent without leaving a stuck zero", async () => {
renderModal();