FN-7061: Graduate Remote Access settings

Remote Access settings are now always available while tunnel prerequisites remain gated.

- Remove Remote Access from experimental feature toggles and section visibility gates.
- Keep legacy remoteAccess experimental flags hidden so upgrades cannot disable the section.
- Update settings and remote access docs plus release notes for the graduated UI.
- Cover always-visible Remote Access behavior across missing and legacy experimental flag states.

Files changed:
 .changeset/fn-7061-remote-access-graduation.md     |  7 +++
 docs/remote-access.md                              |  2 +-
 docs/settings-reference.md                         |  1 +
 .../dashboard/app/components/SettingsModal.tsx     | 17 ++----
 .../SettingsModal.remote-notifications.test.tsx    | 13 +++++
 .../SettingsModal.scheduling-merge.test.tsx        | 60 ++++++++++++----------
 6 files changed, 60 insertions(+), 40 deletions(-)

Fusion-Task-Id: FN-7061

Fusion-Task-Lineage: 2e6a6977-b43b-4c7b-8100-0f2f2f018fcf
This commit is contained in:
gsxdsm
2026-06-26 09:11:07 -07:00
parent 42f46a12c1
commit 2442032382
6 changed files with 60 additions and 40 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Make Remote Access settings visible without enabling an experimental flag.
category: feature
dev: Graduates the Settings UI section while leaving remoteAccess provider/token gating unchanged.

View File

@@ -22,7 +22,7 @@ It documents only behavior implemented in the current codebase:
## 1.1 General requirements
- Remote Access is **global-scoped** (`GlobalSettings.remoteAccess` in `packages/core/src/types.ts`).
- Configure it in dashboard settings (Remote tab) or via `PUT /api/settings/global` or `PUT /api/remote/settings`.
- Configure it in dashboard settings (Remote Access tab, always visible) or via `PUT /api/settings/global` or `PUT /api/remote/settings`.
- A provider must be selected (`remoteAccess.activeProvider`) before tunnel start.
- Start/stop is always manual through `/api/remote/tunnel/start` and `/api/remote/tunnel/stop`.

View File

@@ -779,6 +779,7 @@ See also:
### Remote Access settings (global-scoped)
Remote access settings are global-only (stored in `~/.fusion/settings.json`), not project-scoped.
The Settings → Remote Access section is always visible; starting a tunnel still requires `remoteAccess.enabled`, a selected provider, and provider-specific prerequisites.
The canonical persisted shape is a nested `remoteAccess` object.
Use **[Remote Access runbook](./remote-access.md)** for setup prerequisites (Tailscale/Cloudflare), tokenized login-link security caveats, and operational troubleshooting. Keep this section as a schema reference.

View File

@@ -272,7 +272,6 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
insights: "Insights",
memoryView: "Memory Editor",
remoteAccess: "Remote Access",
skillsView: "Skills View",
nodesView: "Nodes View",
devServerView: "Dev Server",
@@ -304,12 +303,16 @@ Right Dock Panel is no longer experimental: keep honoring the dock as always-on
FNXC:SettingsExperimental 2026-06-23-01:31:
Chat Rooms, Goals, Memory, Insights, Skills, and Todo graduated from Experimental. Hide stale persisted flags so users cannot accidentally disable now-default dashboard surfaces during upgrades.
FNXC:SettingsExperimental 2026-06-26-00:00:
Remote Access graduated from Experimental — section is always available; stale persisted `remoteAccess` flags are hidden so upgrades cannot disable it.
*/
const HIDDEN_EXPERIMENTAL_FEATURE_KEYS = new Set<string>([
"chatRooms",
"goalsView",
"insights",
"memoryView",
"remoteAccess",
"roadmap",
"rightDock",
"skillsView",
@@ -816,14 +819,9 @@ export function SettingsModal({
const { nodes } = useNodes();
const experimentalFeatures = form.experimentalFeatures ?? {};
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView");
const evalsViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "evalsView");
const visibleSections = SETTINGS_SECTIONS.filter((section) => {
if (section.id === "remote") {
return remoteAccessEnabled;
}
if (section.id === "research-global" || section.id === "research-project") {
return researchViewEnabled;
}
@@ -842,11 +840,6 @@ export function SettingsModal({
const activeSectionScope = visibleSections.find((s) => s.id === activeSection)?.scope;
useEffect(() => {
if (activeSection === "remote" && !remoteAccessEnabled) {
setActiveSection(firstVisibleSectionId);
return;
}
if ((activeSection === "research-global" || activeSection === "research-project") && !researchViewEnabled) {
setActiveSection(firstVisibleSectionId);
return;
@@ -860,7 +853,7 @@ export function SettingsModal({
if (!visibleSections.some((section) => section.id === activeSection)) {
setActiveSection(firstVisibleSectionId);
}
}, [activeSection, remoteAccessEnabled, researchViewEnabled, evalsViewEnabled, firstVisibleSectionId, visibleSections]);
}, [activeSection, researchViewEnabled, evalsViewEnabled, firstVisibleSectionId, visibleSections]);
// Auth state (independent of the settings save flow)
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);

View File

@@ -220,6 +220,19 @@ describe("SettingsModal", () => {
await user.click(summary);
};
it("opens the Remote Access section when the legacy experimental flag is absent", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: {},
});
renderModal();
await waitForSettingsModalReady();
await openRemoteSection();
expect(screen.getByRole("heading", { name: "Remote Access" })).toBeInTheDocument();
});
describe("with default Remote render", () => {
beforeEach(async () => {
renderModal();

View File

@@ -1388,53 +1388,59 @@ describe("SettingsModal", () => {
});
describe("section visibility behind experimental flags", () => {
it("hides Remote Access nav item when experimentalFeatures.remoteAccess is falsy", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: {},
});
renderModal();
await waitForSettingsModalReady();
expect(screen.queryByRole("button", { name: /Remote Access/i })).not.toBeInTheDocument();
});
it("shows Remote Access nav item when experimentalFeatures.remoteAccess is true", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { remoteAccess: true },
});
it.each([
["experimentalFeatures undefined", undefined],
["experimentalFeatures empty", {}],
["legacy remoteAccess false", { remoteAccess: false }],
["legacy remoteAccess true", { remoteAccess: true }],
] as const)("shows Remote Access nav item when %s", async (_label, experimentalFeatures) => {
const { experimentalFeatures: _omitted, ...settingsWithoutExperimentalFeatures } = defaultSettings;
mockFetchSettings.mockResolvedValue(
experimentalFeatures === undefined
? settingsWithoutExperimentalFeatures
: {
...defaultSettings,
experimentalFeatures,
},
);
renderModal();
expect(await screen.findByRole("button", { name: /Remote Access/i })).toBeInTheDocument();
});
it("shows Remote Access in KNOWN_EXPERIMENTAL_FEATURES toggle list", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: {},
});
it.each([
["experimentalFeatures undefined", undefined],
["experimentalFeatures empty", {}],
["legacy remoteAccess false", { remoteAccess: false }],
["legacy remoteAccess true", { remoteAccess: true }],
] as const)("does not render a Remote Access experimental toggle when %s", async (_label, experimentalFeatures) => {
const { experimentalFeatures: _omitted, ...settingsWithoutExperimentalFeatures } = defaultSettings;
mockFetchSettings.mockResolvedValue(
experimentalFeatures === undefined
? settingsWithoutExperimentalFeatures
: {
...defaultSettings,
experimentalFeatures,
},
);
renderModal();
await openExperimentalFeaturesSection();
expect(screen.getByLabelText("Remote Access")).toBeInTheDocument();
expect(screen.queryByLabelText("Remote Access")).not.toBeInTheDocument();
});
it("falls back to the first selectable section when opening remote while remoteAccess is disabled", async () => {
it("stays on the remote section when opening remote while remoteAccess is absent", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: {},
});
renderModal({ initialSection: "remote" });
await waitForSettingsModalReady();
expect(screen.queryByRole("button", { name: /Remote Access/i })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Remote Access" })).toBeInTheDocument();
});
it("hides research settings nav items when experimentalFeatures.researchView is disabled", async () => {