FN-8520: hide uninstalled runtimes from Settings

Keep Settings runtime navigation synchronized with installed runtime plugins.

- Filter runtime sections to installed plugins while preserving disabled installed runtimes
- Refresh runtime navigation after plugin lifecycle events and direct plugin mutations
- Add Settings coverage and document runtime visibility behavior

Files changed:
 .changeset/fn-8520-runtime-sidebar.md              |  7 +++
 docs/dashboard-guide.md                            |  2 +-
 .../dashboard/app/components/PluginManager.tsx     | 10 +++-
 .../dashboard/app/components/SettingsModal.tsx     | 70 +++++++++++++++++++++-
 .../__tests__/SettingsModal.general.test.tsx       | 58 ++++++++++++++++++
 .../__tests__/SettingsModal.test-harness.tsx       |  2 +
 .../components/__tests__/settings-mobile.test.tsx  |  1 +
 .../settings/sections/PluginsSection.tsx           |  6 +-
 8 files changed, 149 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-8520

Fusion-Task-Lineage: 914ae503-e1b0-4051-bc01-4da950bf55b3

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-22 20:38:25 -07:00
parent 96a9da7979
commit 0e2aa49f8d
8 changed files with 149 additions and 7 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Hide uninstalled runtime pages from Settings integrations.
category: fix
dev: Settings refreshes installed runtime navigation from plugin lifecycle updates while keeping disabled installed runtimes visible.

View File

@@ -50,7 +50,7 @@ The Settings footer includes a **Reset Settings** button, next to Import/Export,
Both actions are irreversible; there is no undo after confirming. The dialog closes and the form refreshes to show the reset values immediately after a successful reset.
**Excluded sections.** Some sections are not a simple settings form and are intentionally excluded from **Reset this menu** (the button is disabled with an explanatory tooltip when one of these is the active section), because each already has its own dedicated management flow: **Secrets**, **MCP Servers** (global and project), **Plugins**, **Memory**, **Authentication**, **Prompts**, **CLI Agents**, and the **Hermes**/**OpenClaw**/**Paperclip** runtime sections. **Reset all project settings** is unaffected by this exclusion list since it resets the underlying project settings values directly, not through any of those sections' own flows.
**Excluded sections.** Some sections are not a simple settings form and are intentionally excluded from **Reset this menu** (the button is disabled with an explanatory tooltip when one of these is the active section), because each already has its own dedicated management flow: **Secrets**, **MCP Servers** (global and project), **Plugins**, **Memory**, **Authentication**, **Prompts**, **CLI Agents**, and the **Hermes**/**OpenClaw**/**Paperclip** runtime sections. Runtime pages appear only when their runtime plugin is installed; an installed but disabled runtime stays visible so it can be inspected or re-enabled. Settings hides runtime pages while its installed-plugin list is loading or unavailable, then refreshes the navigation after plugin lifecycle changes while Settings remains open. **Reset all project settings** is unaffected by this exclusion list since it resets the underlying project settings values directly, not through any of those sections' own flows.
## Keyboard shortcuts

View File

@@ -42,6 +42,8 @@ interface PluginLifecyclePayload {
interface PluginManagerProps {
addToast: (message: string, type?: ToastType) => void;
projectId?: string;
/** Lets a mounted Settings owner refresh installed-only runtime navigation. */
onPluginsChanged?: () => void;
}
interface BuiltinPlugin {
@@ -264,7 +266,7 @@ function renderPluginError(plugin: PluginInstallation, className = "plugin-error
);
}
export function PluginManager({ addToast, projectId }: PluginManagerProps) {
export function PluginManager({ addToast, projectId, onPluginsChanged }: PluginManagerProps) {
const { t } = useTranslation("app");
const [plugins, setPlugins] = useState<PluginInstallation[]>([]);
const [loading, setLoading] = useState(true);
@@ -506,6 +508,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
setInstallPath("");
setInstallAiScanOnLoad(false);
await loadPlugins();
onPluginsChanged?.();
} catch (err) {
addToast(t("plugins.installFailed", "Failed to install plugin: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error");
} finally {
@@ -524,6 +527,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
await installPlugin({ path: plugin.path }, projectId);
addToast(t("plugins.builtinInstalledGlobally", "{{name}} installed globally", { name: plugin.name }), "success");
await loadPlugins();
onPluginsChanged?.();
} catch (err) {
addToast(t("plugins.builtinInstallFailed", "Failed to install {{name}}: {{error}}", { name: plugin.name, error: err instanceof Error ? err.message : String(err) }), "error");
} finally {
@@ -542,6 +546,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
await installPlugin({ path: entry.path }, projectId);
addToast(t("plugins.registryInstalled", "{{name}} installed and enabled", { name: entry.name }), "success");
await loadPlugins();
onPluginsChanged?.();
await loadRegistry(registrySearchQuery, registryCategory);
} catch (err) {
addToast(t("plugins.registryInstallFailed", "Failed to install {{name}}: {{error}}", { name: entry.name, error: err instanceof Error ? err.message : String(err) }), "error");
@@ -590,6 +595,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
// FNXC:PluginEnablementScope 2026-07-21-16:30: Preserve this authoritative
// response if the single confirmation refresh races a stale scoped-list read.
await loadPlugins(true, enabledPlugin);
onPluginsChanged?.();
} catch (err) {
addToast(t("plugins.enablePluginFailed", "Failed to enable plugin: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error");
}
@@ -601,6 +607,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
setPlugins((previous) => previous.map((entry) => entry.id === disabledPlugin.id ? disabledPlugin : entry));
addToast(t("plugins.disabledForProject", "{{name}} disabled for this project", { name: plugin.name }), "success");
await loadPlugins(true, disabledPlugin);
onPluginsChanged?.();
} catch (err) {
addToast(t("plugins.disablePluginFailed", "Failed to disable plugin: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error");
}
@@ -675,6 +682,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
await uninstallPlugin(plugin.id, projectId);
addToast(t("plugins.uninstalledGlobally", "{{name}} uninstalled globally", { name: plugin.name }), "success");
await loadPlugins();
onPluginsChanged?.();
setSelectedPlugin(null);
} catch (err) {
addToast(t("plugins.uninstallFailed", "Failed to uninstall plugin: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error");

View File

@@ -8,7 +8,7 @@ import {
} from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
import { DEFAULT_GLOBAL_SETTINGS } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchSystemInfo, requestSystemRestart, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchSystemInfo, requestSystemRestart, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode, fetchPlugins } from "../api";
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, UpdateInstallResponse, OAuthDeviceCodeInfo } from "../api";
import { resolveScopedMcpSettings, splitSettingsSave, type McpSettingsScope } from "./settings/save-split";
import {
@@ -90,6 +90,7 @@ import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibilit
import { SETTINGS_SEARCH_ENTRIES } from "./settings/search/entries";
import { rankSettingsSearchResults, matchedSectionIds } from "./settings/search/match";
import { SettingsSearchHighlightProvider } from "./settings/SettingsSearchHighlightContext";
import { subscribeSse } from "../sse-bus";
// ---------------------------------------------------------------------------
// GitHub star count — cached locally and refreshed only while Settings is visible.
@@ -304,6 +305,14 @@ Settings opens in a focused mode that omits specialist integration, runtime, dia
FNXC:SettingsNavigation 2026-07-16-12:00:
FN-8128 returns CLI Binary to the default Settings view. It is deliberately absent from this Advanced-only set so desktop navigation, the mobile picker, and search expose binary install and diagnostic controls without requiring the browser-local Advanced preference.
*/
const RUNTIME_PLUGIN_SECTION_IDS: ReadonlyMap<string, string> = new Map([
["fusion-plugin-hermes-runtime", "hermes-runtime"],
["fusion-plugin-openclaw-runtime", "openclaw-runtime"],
["fusion-plugin-paperclip-runtime", "paperclip-runtime"],
] as const);
const RUNTIME_SETTINGS_SECTION_IDS = new Set(RUNTIME_PLUGIN_SECTION_IDS.values());
const ADVANCED_SETTINGS_SECTION_IDS = new Set([
"node-sync",
"global-mcp",
@@ -1258,6 +1267,55 @@ export function SettingsModal({
}, [form]);
// Find the first non-group-header section for visibility fallback handling
const firstNonHeaderSection = SETTINGS_SECTIONS.find((s) => !s.isGroupHeader);
const [installedRuntimeSectionIds, setInstalledRuntimeSectionIds] = useState<Set<string>>(() => new Set());
const [runtimeVisibilitySettled, setRuntimeVisibilitySettled] = useState(false);
const runtimeVisibilityRequestRef = useRef(0);
const refreshInstalledRuntimeSections = useCallback(async () => {
/*
FNXC:SettingsRuntimeNavigation 2026-07-22-12:00:
Runtime settings pages are backed only by installed plugin records, not the built-in catalog or runtime binary detection. Disabled installed records remain navigable so operators can inspect and re-enable them.
Every refresh clears visibility first: loading, undefined lists, and errors fail closed until a successful project-scoped GET /plugins response restores precisely its deduplicated runtime records.
*/
const requestId = ++runtimeVisibilityRequestRef.current;
setRuntimeVisibilitySettled(false);
setInstalledRuntimeSectionIds(new Set());
try {
const plugins = await fetchPlugins(projectId);
const next = new Set<string>();
for (const plugin of plugins ?? []) {
const sectionId = RUNTIME_PLUGIN_SECTION_IDS.get(plugin.id);
if (sectionId) next.add(sectionId);
}
if (requestId !== runtimeVisibilityRequestRef.current) return;
setInstalledRuntimeSectionIds(next);
setRuntimeVisibilitySettled(true);
} catch {
// Fail closed; a later successful lifecycle/reconnect refresh restores entries.
if (requestId !== runtimeVisibilityRequestRef.current) return;
setInstalledRuntimeSectionIds(new Set());
setRuntimeVisibilitySettled(true);
}
}, [projectId]);
useEffect(() => {
void refreshInstalledRuntimeSections();
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
return subscribeSse(`/api/events${query}`, {
events: {
"plugin:lifecycle": (event) => {
try {
const payload = JSON.parse(event.data) as { scope?: string; projectId?: string };
if (payload.scope === "project" && (payload.projectId ?? projectId) !== projectId) return;
void refreshInstalledRuntimeSections();
} catch {
// Ignore malformed lifecycle data; reconnect still re-syncs authoritative state.
}
},
},
onReconnect: () => void refreshInstalledRuntimeSections(),
});
}, [projectId, refreshInstalledRuntimeSections]);
const [activeSection, setActiveSection] = useState<SectionId>(() => {
if (initialSection === "pi-extensions") {
return "plugins";
@@ -1406,6 +1464,10 @@ export function SettingsModal({
return false;
}
if (RUNTIME_SETTINGS_SECTION_IDS.has(section.id) && !installedRuntimeSectionIds.has(section.id)) {
return false;
}
if (section.id === "research-global" || section.id === "research-project") {
return researchViewEnabled;
}
@@ -1415,7 +1477,7 @@ export function SettingsModal({
}
return true;
})), [researchViewEnabled, evalsViewEnabled, showAdvancedSettings]);
})), [researchViewEnabled, evalsViewEnabled, installedRuntimeSectionIds, showAdvancedSettings]);
const firstVisibleSectionId = visibleSections.some((section) => section.id === DEFAULT_SETTINGS_SECTION)
? DEFAULT_SETTINGS_SECTION
: resolveFirstSelectableSettingsSection(visibleSections, firstNonHeaderSection?.id ?? "general");
@@ -1545,6 +1607,7 @@ export function SettingsModal({
}
if (!visibleSections.some((section) => section.id === activeSection)) {
if (RUNTIME_SETTINGS_SECTION_IDS.has(activeSection) && !runtimeVisibilitySettled) return;
setActiveSection(firstVisibleSectionId);
return;
}
@@ -1552,7 +1615,7 @@ export function SettingsModal({
if (hasSettingsSearchQuery && hasSettingsSearchResults && !searchMatchedSections.some((section) => section.id === activeSection)) {
setActiveSection(firstSearchMatchedSectionId);
}
}, [activeSection, researchViewEnabled, evalsViewEnabled, firstVisibleSectionId, firstSearchMatchedSectionId, hasSettingsSearchQuery, hasSettingsSearchResults, searchMatchedSections, visibleSections]);
}, [activeSection, researchViewEnabled, evalsViewEnabled, firstVisibleSectionId, firstSearchMatchedSectionId, hasSettingsSearchQuery, hasSettingsSearchResults, runtimeVisibilitySettled, searchMatchedSections, visibleSections]);
// Auth state (independent of the settings save flow)
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
@@ -4438,6 +4501,7 @@ export function SettingsModal({
addToast={addToast}
activePluginsSubsection={activePluginsSubsection}
setActivePluginsSubsection={setActivePluginsSubsection}
onPluginsChanged={refreshInstalledRuntimeSections}
/>
);
case "authentication":

View File

@@ -58,6 +58,7 @@ import {
mockFetchRemoteUrl,
mockTriggerMemoryDreams,
mockFetchPluginUiSlots,
mockFetchPlugins,
mockFetchDroidCliStatus,
mockSetDroidCliEnabled,
mockFetchCursorCliStatus,
@@ -79,6 +80,15 @@ import {
} from "./SettingsModal.test-harness";
const mockListDiscussionCategories = vi.fn(async () => ({ categories: [] }));
let pluginLifecycleListener: ((event: MessageEvent) => void) | undefined;
const mockSubscribeSse = vi.fn((_url: string, options: { events?: Record<string, (event: MessageEvent) => void> }) => {
pluginLifecycleListener = options.events?.["plugin:lifecycle"];
return () => {};
});
vi.mock("../../sse-bus", () => ({
subscribeSse: (...args: unknown[]) => mockSubscribeSse(...args),
}));
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
@@ -138,6 +148,7 @@ vi.mock("../../api", async (importOriginal) => {
fetchRemoteUrl: (...args: unknown[]) => mockFetchRemoteUrl(...args),
triggerMemoryDreams: (...args: unknown[]) => mockTriggerMemoryDreams(...args),
fetchPluginUiSlots: (...args: unknown[]) => mockFetchPluginUiSlots(...args),
fetchPlugins: (...args: unknown[]) => mockFetchPlugins(...args),
fetchDroidCliStatus: (...args: unknown[]) => mockFetchDroidCliStatus(...args),
setDroidCliEnabled: (...args: unknown[]) => mockSetDroidCliEnabled(...args),
fetchCursorCliStatus: (...args: unknown[]) => mockFetchCursorCliStatus(...args),
@@ -211,6 +222,53 @@ describe("SettingsModal", () => {
// Keep Advanced off by default so disclosure default/persist tests stay truthful.
installSettingsModalEnv({ advancedSettings: false });
it("shows only installed runtime pages and keeps disabled installations navigable", async () => {
mockFetchPlugins.mockResolvedValue([
{ id: "fusion-plugin-openclaw-runtime", enabled: false },
{ id: "fusion-plugin-openclaw-runtime", enabled: false },
]);
renderModal({ initialSection: "openclaw-runtime" });
await waitForSettingsModalReady();
await waitFor(() => expect(screen.getByRole("button", { name: /OpenClaw/ })).toBeInTheDocument());
expect(screen.queryByRole("button", { name: /Hermes/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Paperclip/ })).not.toBeInTheDocument();
});
it("refreshes active runtime navigation after an external lifecycle uninstall", async () => {
mockFetchPlugins.mockResolvedValue([{ id: "fusion-plugin-openclaw-runtime", enabled: true }]);
renderModal({ initialSection: "openclaw-runtime", projectId: "project-a" });
await waitForSettingsModalReady();
await waitFor(() => expect(screen.getByRole("button", { name: /OpenClaw/ })).toBeInTheDocument());
expect(pluginLifecycleListener).toBeTypeOf("function");
mockFetchPlugins.mockResolvedValueOnce([]);
await act(async () => {
pluginLifecycleListener?.(new MessageEvent("plugin:lifecycle", {
data: JSON.stringify({ scope: "project", projectId: "project-a", transition: "uninstalled" }),
}));
});
await waitFor(() => expect(screen.queryByRole("button", { name: /OpenClaw/ })).not.toBeInTheDocument());
expect(screen.getByRole("heading", { name: "Appearance" })).toBeInTheDocument();
mockFetchPlugins.mockResolvedValueOnce([{ id: "fusion-plugin-openclaw-runtime", enabled: true }]);
await act(async () => {
pluginLifecycleListener?.(new MessageEvent("plugin:lifecycle", {
data: JSON.stringify({ scope: "project", projectId: "project-a", transition: "installed" }),
}));
});
await waitFor(() => expect(screen.getByRole("button", { name: /OpenClaw/ })).toBeInTheDocument());
});
it("fails closed and falls back from an uninstalled initial runtime section", async () => {
mockFetchPlugins.mockResolvedValue([]);
renderModal({ initialSection: "openclaw-runtime" });
await waitForSettingsModalReady();
await waitFor(() => expect(mockFetchPlugins).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: /OpenClaw/ })).not.toBeInTheDocument();
expect(screen.queryByText("OpenClaw Runtime")).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Appearance" })).toBeInTheDocument();
});
afterEach(() => {
viewportMode = "mobile";
mockListDiscussionCategories.mockReset();

View File

@@ -78,6 +78,7 @@ export const mockFetchRemoteQr = vi.fn();
export const mockFetchRemoteUrl = vi.fn();
export const mockTriggerMemoryDreams = vi.fn();
export const mockFetchPluginUiSlots = vi.fn();
export const mockFetchPlugins = vi.fn();
export const mockFetchDroidCliStatus = vi.fn();
export const mockSetDroidCliEnabled = vi.fn();
export const mockFetchCursorCliStatus = vi.fn();
@@ -452,6 +453,7 @@ export function installSettingsModalEnv(options?: { advancedSettings?: boolean }
mockFetchRemoteUrl.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null });
mockTriggerMemoryDreams.mockResolvedValue({ success: true, summary: "done" });
mockFetchPluginUiSlots.mockResolvedValue([]);
mockFetchPlugins.mockResolvedValue([]);
mockFetchDroidCliStatus.mockResolvedValue({
binary: { available: true, version: "1.2.3", binaryPath: "/usr/local/bin/droid", probeDurationMs: 9 },
enabled: false,

View File

@@ -42,6 +42,7 @@ const defaultSettings = {
vi.mock("../../api", () => ({
fetchProjects: vi.fn(() => Promise.resolve([])),
fetchPlugins: vi.fn(() => Promise.resolve([])),
fetchGitRemotes: vi.fn(() => Promise.resolve({ remotes: [] })),
fetchGitRemotesDetailed: vi.fn(() => Promise.resolve([])),
fetchGitBranches: vi.fn(() => Promise.resolve([])),

View File

@@ -10,8 +10,10 @@ export interface PluginsSectionProps {
addToast: (message: string, type?: ToastType) => void;
activePluginsSubsection: PluginsSubsectionId;
setActivePluginsSubsection: (id: PluginsSubsectionId) => void;
/** Invalidates Settings-owned runtime navigation after a direct mutation. */
onPluginsChanged?: () => void;
}
export function PluginsSection({ projectId, addToast, activePluginsSubsection, setActivePluginsSubsection, }: PluginsSectionProps) {
export function PluginsSection({ projectId, addToast, activePluginsSubsection, setActivePluginsSubsection, onPluginsChanged, }: PluginsSectionProps) {
const { t } = useTranslation("app");
return (<>
<h4 className="settings-section-heading">{t("settings.plugins.plugins", "Plugins")}</h4>
@@ -22,7 +24,7 @@ export function PluginsSection({ projectId, addToast, activePluginsSubsection, s
<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}/>
<PluginManager addToast={addToast} projectId={projectId} onPluginsChanged={onPluginsChanged}/>
</Suspense>
<PluginSlot slotId="settings-section" projectId={projectId}/>
</>)}