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