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:
7
.changeset/fn-8520-runtime-sidebar.md
Normal file
7
.changeset/fn-8520-runtime-sidebar.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([])),
|
||||
|
||||
@@ -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}/>
|
||||
</>)}
|
||||
|
||||
Reference in New Issue
Block a user