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 01f3e70917
commit d09ae5b8df
3 changed files with 197 additions and 15 deletions

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