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);
|
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 */
|
/* Scope indicators in sidebar nav items */
|
||||||
.settings-scope-icon {
|
.settings-scope-icon {
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
@@ -676,6 +722,17 @@
|
|||||||
margin: 0 var(--space-lg) 0;
|
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 {
|
.form-group {
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import "./SettingsModal.css";
|
|||||||
*
|
*
|
||||||
* Group headers (isGroupHeader: true) are non-clickable labels that visually group sections.
|
* Group headers (isGroupHeader: true) are non-clickable labels that visually group sections.
|
||||||
* The sidebar is organized into three groups:
|
* 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)
|
* - Global: Global-scoped sections (appearance, notifications, node-sync, global-models)
|
||||||
* - Project: Project-scoped sections (project-models, general, scheduling, worktrees, commands,
|
* - Project: Project-scoped sections (project-models, general, scheduling, worktrees, commands,
|
||||||
* merge, memory, experimental, prompts, backups, plugins)
|
* merge, memory, experimental, prompts, backups, plugins)
|
||||||
@@ -54,7 +54,6 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
|||||||
// Account group (scope-less items — independent of settings storage)
|
// Account group (scope-less items — independent of settings storage)
|
||||||
{ id: "__account_header", label: "Account", scope: undefined, isGroupHeader: true },
|
{ id: "__account_header", label: "Account", scope: undefined, isGroupHeader: true },
|
||||||
{ id: "authentication", label: "Authentication", scope: undefined, icon: Globe },
|
{ id: "authentication", label: "Authentication", scope: undefined, icon: Globe },
|
||||||
{ id: "pi-extensions", label: "Pi Extensions", scope: undefined },
|
|
||||||
|
|
||||||
// Global group (shared across all Fusion projects)
|
// Global group (shared across all Fusion projects)
|
||||||
{ id: "__global_header", label: "Global", scope: undefined, isGroupHeader: true },
|
{ id: "__global_header", label: "Global", scope: undefined, isGroupHeader: true },
|
||||||
@@ -102,7 +101,10 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
|
|||||||
devServerView: "Dev Server View",
|
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). */
|
/** Local form state extends Settings with a worktreeInitCommand override and lets tokenCap carry null (delete semantic). */
|
||||||
type SettingsFormState = Settings & { worktreeInitCommand?: string; tokenCap?: number | null };
|
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);
|
const [initialScopedValues, setInitialScopedValues] = useState<{ global: GlobalSettings; project: Partial<Settings> } | null>(null);
|
||||||
// Find the first non-group-header section for default active section
|
// Find the first non-group-header section for default active section
|
||||||
const firstNonHeaderSection = SETTINGS_SECTIONS.find((s) => !s.isGroupHeader);
|
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(() =>
|
const [showMobileSectionPicker, setShowMobileSectionPicker] = useState(() =>
|
||||||
typeof window !== "undefined" && typeof window.matchMedia === "function"
|
typeof window !== "undefined" && typeof window.matchMedia === "function"
|
||||||
? window.matchMedia(MOBILE_SETTINGS_MEDIA_QUERY)?.matches === true
|
? window.matchMedia(MOBILE_SETTINGS_MEDIA_QUERY)?.matches === true
|
||||||
@@ -3110,18 +3122,63 @@ export function SettingsModal({
|
|||||||
<>
|
<>
|
||||||
{renderScopeBanner()}
|
{renderScopeBanner()}
|
||||||
<h4 className="settings-section-heading">Plugins</h4>
|
<h4 className="settings-section-heading">Plugins</h4>
|
||||||
<Suspense fallback={null}>
|
<div className="settings-plugins-subsection-toggle" role="tablist" aria-label="Plugin manager type">
|
||||||
<PluginManager addToast={addToast} projectId={projectId} />
|
<button
|
||||||
</Suspense>
|
type="button"
|
||||||
<PluginSlot slotId="settings-section" projectId={projectId} />
|
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": {
|
case "authentication": {
|
||||||
// CLI-backed providers (currently just claude-cli) render their own
|
// CLI-backed providers (currently just claude-cli) render their own
|
||||||
// compact card with Enable/Disable + Test actions — bypassing the
|
// compact card with Enable/Disable + Test actions — bypassing the
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { SettingsModal } from "../SettingsModal";
|
import { SettingsModal } from "../SettingsModal";
|
||||||
import type { SettingsExportData } from "../../api";
|
import type { SettingsExportData } from "../../api";
|
||||||
@@ -61,6 +61,18 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({
|
|||||||
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
|
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 noop = () => {};
|
||||||
|
|
||||||
const defaultSettings = {
|
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", () => {
|
describe("SettingsModal", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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", () => {
|
describe("Number input clearing", () => {
|
||||||
it("allows clearing maxConcurrent without leaving a stuck zero", async () => {
|
it("allows clearing maxConcurrent without leaving a stuck zero", async () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
|
|||||||
Reference in New Issue
Block a user