diff --git a/.changeset/fn-8520-runtime-sidebar.md b/.changeset/fn-8520-runtime-sidebar.md new file mode 100644 index 0000000000..3723780a71 --- /dev/null +++ b/.changeset/fn-8520-runtime-sidebar.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c270eb8864..0880c5c9ee 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 diff --git a/packages/dashboard/app/components/PluginManager.tsx b/packages/dashboard/app/components/PluginManager.tsx index cf157a26c8..39d5c2d000 100644 --- a/packages/dashboard/app/components/PluginManager.tsx +++ b/packages/dashboard/app/components/PluginManager.tsx @@ -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([]); 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"); diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 6f4764f3c4..52f48cd049 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -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 = 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>(() => 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(); + 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(() => { 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([]); @@ -4438,6 +4501,7 @@ export function SettingsModal({ addToast={addToast} activePluginsSubsection={activePluginsSubsection} setActivePluginsSubsection={setActivePluginsSubsection} + onPluginsChanged={refreshInstalledRuntimeSections} /> ); case "authentication": diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index 3624bc77d9..7e17015975 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -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 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(); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx index 89bf1ad52c..5fae2b20d8 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx @@ -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, diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index 85065afdb1..3e0d46680e 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -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([])), diff --git a/packages/dashboard/app/components/settings/sections/PluginsSection.tsx b/packages/dashboard/app/components/settings/sections/PluginsSection.tsx index fbebbb28ad..a7a7347956 100644 --- a/packages/dashboard/app/components/settings/sections/PluginsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/PluginsSection.tsx @@ -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 (<>

{t("settings.plugins.plugins", "Plugins")}

@@ -22,7 +24,7 @@ export function PluginsSection({ projectId, addToast, activePluginsSubsection, s