feat(FN-3045): gate research settings sections behind feature flags

Research settings sections in the SettingsModal are now gated behind the feature flag, with docs updated to document the gating behavior. Tests cover both desktop and mobile settings views for the new gating logic.

Fusion-Task-Id: FN-3045
This commit is contained in:
Fusion
2026-04-30 23:56:18 -07:00
committed by gsxdsm
parent 444032c58d
commit 3563881db5
5 changed files with 123 additions and 9 deletions

View File

@@ -397,8 +397,20 @@ export function SettingsModal({
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId);
const { nodes } = useNodes();
const remoteAccessEnabled = isExperimentalFeatureEnabled(form.experimentalFeatures ?? {}, "remoteAccess");
const visibleSections = SETTINGS_SECTIONS.filter((section) => section.id !== "remote" || remoteAccessEnabled);
const experimentalFeatures = form.experimentalFeatures ?? {};
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView");
const visibleSections = SETTINGS_SECTIONS.filter((section) => {
if (section.id === "remote") {
return remoteAccessEnabled;
}
if (section.id === "research-global" || section.id === "research-project") {
return researchViewEnabled;
}
return true;
});
const firstVisibleSectionId = visibleSections.find((section) => !section.isGroupHeader)?.id ?? "general";
/** Get the scope of the currently active section */
@@ -410,10 +422,15 @@ export function SettingsModal({
return;
}
if ((activeSection === "research-global" || activeSection === "research-project") && !researchViewEnabled) {
setActiveSection(firstVisibleSectionId);
return;
}
if (!visibleSections.some((section) => section.id === activeSection)) {
setActiveSection(firstVisibleSectionId);
}
}, [activeSection, remoteAccessEnabled, firstVisibleSectionId, visibleSections]);
}, [activeSection, remoteAccessEnabled, researchViewEnabled, firstVisibleSectionId, visibleSections]);
// Auth state (independent of the settings save flow)
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);

View File

@@ -1540,7 +1540,7 @@ describe("SettingsModal", () => {
expect(devServerToggles[0]).toBeChecked();
});
describe("Remote Access section visibility", () => {
describe("section visibility behind experimental flags", () => {
it("hides Remote Access nav item when experimentalFeatures.remoteAccess is falsy", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
@@ -1589,6 +1589,45 @@ describe("SettingsModal", () => {
expect(screen.queryByRole("button", { name: /Remote Access/i })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument();
});
it("hides research settings nav items when experimentalFeatures.researchView is disabled", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: {},
});
renderModal();
await waitForSettingsModalReady();
expect(screen.queryByRole("button", { name: /Research Defaults/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^Research$/i })).not.toBeInTheDocument();
});
it("shows research settings nav items when experimentalFeatures.researchView is enabled", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { researchView: true },
});
renderModal();
await waitForSettingsModalReady();
expect(screen.getByRole("button", { name: /Research Defaults/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^Research$/i })).toBeInTheDocument();
});
it("falls back to the first selectable section when opening research settings while researchView is disabled", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: {},
});
renderModal({ initialSection: "research-global" });
await waitForSettingsModalReady();
expect(screen.queryByRole("button", { name: /Research Defaults/i })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument();
});
});
it("sends canonical devServerView=false and devServer=null when disabling legacy dev server flag", async () => {
@@ -2298,9 +2337,25 @@ describe("SettingsModal", () => {
});
describe("research settings sections", () => {
beforeEach(() => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { researchView: true },
});
});
const openResearchGlobalSection = async () => {
await userEvent.click(await screen.findByRole("button", { name: /Research Defaults/i }));
};
const openResearchProjectSection = async () => {
await userEvent.click(await screen.findByRole("button", { name: /^Research$/i }));
};
it("saves global research defaults through updateGlobalSettings only", async () => {
renderModal({ initialSection: "research-global" });
renderModal();
await waitForSettingsModalReady();
await openResearchGlobalSection();
const searchInput = await screen.findByLabelText("Default Search Provider");
await userEvent.clear(searchInput);
@@ -2321,8 +2376,9 @@ describe("SettingsModal", () => {
});
it("saves project research settings through updateSettings only", async () => {
renderModal({ initialSection: "research-project" });
renderModal();
await waitForSettingsModalReady();
await openResearchProjectSection();
await userEvent.click(screen.getByLabelText("Enable research in this project"));
const maxConcurrent = await screen.findByLabelText("Max Concurrent Runs");
@@ -2346,8 +2402,9 @@ describe("SettingsModal", () => {
});
it("blocks save and shows inline error for invalid research limits", async () => {
renderModal({ initialSection: "research-project" });
renderModal();
await waitForSettingsModalReady();
await openResearchProjectSection();
const maxConcurrent = await screen.findByLabelText("Max Concurrent Runs");
fireEvent.change(maxConcurrent, { target: { value: "0" } });
@@ -2357,15 +2414,23 @@ describe("SettingsModal", () => {
});
it("shows missing credentials warning and routes CTA to Authentication", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { researchView: true },
researchGlobalDefaults: {
searchProvider: "brave",
},
});
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "brave", name: "Brave Search", type: "api_key", authenticated: false },
{ id: "tavily", name: "Tavily", type: "api_key", authenticated: true },
],
});
renderModal({ initialSection: "research-global" });
renderModal();
await waitForSettingsModalReady();
await openResearchGlobalSection();
expect(await screen.findByText(/Missing credentials for one or more research providers/i)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Open Authentication" }));

View File

@@ -195,6 +195,36 @@ describe("SettingsModal mobile adaptations", () => {
expect(await findByText("Version 1.2.3")).toBeTruthy();
});
it("excludes research sections from mobile picker when researchView is disabled", async () => {
mockSettingsViewport(true);
const user = userEvent.setup();
const { getByLabelText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const picker = getByLabelText("Settings Section") as HTMLSelectElement;
expect(Array.from(picker.options).map((opt) => opt.value)).not.toContain("research-global");
expect(Array.from(picker.options).map((opt) => opt.value)).not.toContain("research-project");
await user.selectOptions(picker, "memory");
expect((picker as HTMLSelectElement).value).toBe("memory");
});
it("includes research sections in mobile picker when researchView is enabled", async () => {
vi.mocked(fetchSettings).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { researchView: true },
});
mockSettingsViewport(true);
const { getByLabelText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const picker = getByLabelText("Settings Section") as HTMLSelectElement;
const optionValues = Array.from(picker.options).map((opt) => opt.value);
expect(optionValues).toContain("research-global");
expect(optionValues).toContain("research-project");
});
it("can open memory settings from the mobile section picker", async () => {
mockSettingsViewport(true);
const user = userEvent.setup();