feat(FN-2027): gate Insights and Roadmaps views behind experimental flags

- Add insightsEnabled and roadmapEnabled state in useAppSettings derived from project experimentalFeatures.
- Pass experimental feature visibility into Header and MobileNavBar and hide Insights/Roadmaps navigation controls unless enabled.
- Redirect users back to Board when a disabled Insights or Roadmaps view is active after settings load.
- Update Settings modal to always show labeled Insights and Roadmaps feature toggles alongside any custom experimental flags.
- Expand App, Header, MobileNavBar, and SettingsModal tests to cover gated rendering and behavior.
This commit is contained in:
Fusion
2026-04-17 18:47:40 -07:00
committed by gsxdsm
parent 77f384ca66
commit 8711c10922
9 changed files with 207 additions and 99 deletions

View File

@@ -190,6 +190,8 @@ function AppInner() {
showQuickChatFAB,
githubTokenConfigured,
experimentalFeatures,
insightsEnabled,
roadmapEnabled,
toggleAutoMerge,
toggleGlobalPause,
toggleEnginePause,
@@ -199,6 +201,18 @@ function AppInner() {
const skillsEnabled = experimentalFeatures.skillsView === true;
const nodesEnabled = experimentalFeatures.nodesView === true;
// Redirect to board if insights/roadmaps view is disabled
// Only run after settings have been loaded (experimentalFeatures is non-empty)
useEffect(() => {
if (Object.keys(experimentalFeatures).length === 0) return;
if (taskView === "insights" && !insightsEnabled) {
handleChangeTaskView("board");
}
if (taskView === "roadmaps" && !roadmapEnabled) {
handleChangeTaskView("board");
}
}, [taskView, insightsEnabled, roadmapEnabled, experimentalFeatures, handleChangeTaskView]);
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
useEffect(() => {
if (nodesOpen && !nodesEnabled) {
@@ -589,6 +603,7 @@ function AppInner() {
}
}}
isRemote={isRemote}
experimentalFeatures={{ insights: insightsEnabled, roadmap: roadmapEnabled }}
/>
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && (
<SessionNotificationBanner
@@ -650,6 +665,7 @@ function AppInner() {
onOpenQuickChat={() => setQuickChatOpen(true)}
projectId={currentProject?.id}
showSkillsTab={skillsEnabled}
experimentalFeatures={{ insights: insightsEnabled, roadmap: roadmapEnabled }}
/>
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && (
<QuickChatFAB

View File

@@ -206,6 +206,8 @@ export interface HeaderProps {
onSelectNode?: (node: NodeConfig | null) => void;
/** Whether the current view is a remote node */
isRemote?: boolean;
/** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: { insights?: boolean; roadmap?: boolean };
}
export function Header({
@@ -248,6 +250,7 @@ export function Header({
currentNode,
onSelectNode,
isRemote = false,
experimentalFeatures,
}: HeaderProps) {
const mode: ViewportMode = useViewportMode();
const isMobile = mode === "mobile";
@@ -747,24 +750,28 @@ export function Header({
<Zap size={16} />
</button>
)}
<button
className={`view-toggle-btn${view === "roadmaps" ? " active" : ""}`}
onClick={() => onChangeView("roadmaps")}
title="Roadmaps view"
aria-label="Roadmaps view"
aria-pressed={view === "roadmaps"}
>
<Map size={16} />
</button>
<button
className={`view-toggle-btn${view === "insights" ? " active" : ""}`}
onClick={() => onChangeView("insights")}
title="Insights view"
aria-label="Insights view"
aria-pressed={view === "insights"}
>
<Sparkles size={16} />
</button>
{experimentalFeatures?.roadmap && (
<button
className={`view-toggle-btn${view === "roadmaps" ? " active" : ""}`}
onClick={() => onChangeView("roadmaps")}
title="Roadmaps view"
aria-label="Roadmaps view"
aria-pressed={view === "roadmaps"}
>
<Map size={16} />
</button>
)}
{experimentalFeatures?.insights && (
<button
className={`view-toggle-btn${view === "insights" ? " active" : ""}`}
onClick={() => onChangeView("insights")}
title="Insights view"
aria-label="Insights view"
aria-pressed={view === "insights"}
>
<Sparkles size={16} />
</button>
)}
</div>
)}

View File

@@ -58,6 +58,8 @@ export interface MobileNavBarProps {
onViewAllProjects?: () => void;
/** Whether to show the skills tab */
showSkillsTab?: boolean;
/** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: { insights?: boolean; roadmap?: boolean };
}
function GitHubLogo({ size = 20 }: { size?: number }) {
@@ -103,6 +105,7 @@ export function MobileNavBar({
projectId,
onViewAllProjects,
showSkillsTab,
experimentalFeatures,
}: MobileNavBarProps) {
const mode = useViewportMode();
const [isMoreOpen, setIsMoreOpen] = useState(false);
@@ -258,17 +261,19 @@ export function MobileNavBar({
</button>
)}
<button
type="button"
className={`mobile-nav-tab${view === "roadmaps" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-roadmaps"
role="tab"
aria-selected={view === "roadmaps"}
onClick={() => onChangeView("roadmaps")}
>
<Map />
<span className="mobile-nav-tab-label">Roadmaps</span>
</button>
{experimentalFeatures?.roadmap && (
<button
type="button"
className={`mobile-nav-tab${view === "roadmaps" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-roadmaps"
role="tab"
aria-selected={view === "roadmaps"}
onClick={() => onChangeView("roadmaps")}
>
<Map />
<span className="mobile-nav-tab-label">Roadmaps</span>
</button>
)}
<button
type="button"
@@ -505,25 +510,29 @@ export function MobileNavBar({
<span>Documents</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-roadmaps"
onClick={() => handleMoreAction(() => onChangeView("roadmaps"))}
>
<Map />
<span>Roadmaps</span>
</button>
{experimentalFeatures?.roadmap && (
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-roadmaps"
onClick={() => handleMoreAction(() => onChangeView("roadmaps"))}
>
<Map />
<span>Roadmaps</span>
</button>
)}
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-insights"
onClick={() => handleMoreAction(() => onChangeView("insights"))}
>
<Sparkles />
<span>Insights</span>
</button>
{experimentalFeatures?.insights && (
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-insights"
onClick={() => handleMoreAction(() => onChangeView("insights"))}
>
<Sparkles />
<span>Insights</span>
</button>
)}
<div className="mobile-more-separator" />

View File

@@ -644,7 +644,7 @@ describe("SettingsModal", () => {
expect(screen.getByText("Experimental Features")).toBeDefined();
});
it("shows empty state message when no experimental features are configured", async () => {
it("shows known experimental features (Insights, Roadmaps) even when no custom features are configured", async () => {
renderModal();
await waitFor(() => {
@@ -653,7 +653,9 @@ describe("SettingsModal", () => {
await userEvent.click(screen.getByText("Experimental Features"));
expect(screen.getByText(/No experimental features configured/i)).toBeInTheDocument();
// Known features should always be shown
expect(screen.getByText("Insights")).toBeInTheDocument();
expect(screen.getByText("Roadmaps")).toBeInTheDocument();
});
it("shows feature flags when experimentalFeatures is set", async () => {
@@ -774,7 +776,7 @@ describe("SettingsModal", () => {
expect(screen.getByText(/only affect this project/i)).toBeInTheDocument();
});
it("handles undefined experimentalFeatures (falls back to empty)", async () => {
it("handles undefined experimentalFeatures (falls back to empty) but still shows known features", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: undefined,
@@ -788,8 +790,9 @@ describe("SettingsModal", () => {
await userEvent.click(screen.getByText("Experimental Features"));
// Should show empty state since undefined falls back to {}
expect(screen.getByText(/No experimental features configured/i)).toBeInTheDocument();
// Known features should always be shown regardless of settings
expect(screen.getByText("Insights")).toBeInTheDocument();
expect(screen.getByText("Roadmaps")).toBeInTheDocument();
});
it("saves experimentalFeatures with multiple toggled flags", async () => {

View File

@@ -77,6 +77,14 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const AUTO_ARCHIVE_DEFAULT_AFTER_DAYS = 2;
/** Well-known experimental feature flags with display labels.
* These always appear in the Experimental Features settings tab,
* regardless of whether they exist in the project's settings blob. */
const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
insights: "Insights",
roadmap: "Roadmaps",
};
export type SectionId = SettingsSection["id"];
interface SettingsModalProps {
@@ -2522,7 +2530,11 @@ export function SettingsModal({
}
case "experimental": {
const experimentalFeatures = form.experimentalFeatures ?? {};
const featureFlags = Object.entries(experimentalFeatures).sort(([a], [b]) => a.localeCompare(b));
// Merge known features (always shown) with any custom features from settings
const allFeatureKeys = Array.from(
new Set([...Object.keys(KNOWN_EXPERIMENTAL_FEATURES), ...Object.keys(experimentalFeatures)])
).sort((a, b) => a.localeCompare(b));
const featureFlags = allFeatureKeys.map((key) => [key, experimentalFeatures[key] === true] as const);
return (
<>
@@ -2535,38 +2547,30 @@ export function SettingsModal({
</small>
</div>
{featureFlags.length === 0 ? (
<div className="form-group">
<small className="settings-muted">
No experimental features configured. Features will appear here once added by the system.
</small>
<div className="form-group">
<label>Feature Flags</label>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)" }}>
{featureFlags.map(([key, enabled]) => (
<label key={key} htmlFor={`experimental-${key}`} className="checkbox-label">
<input
id={`experimental-${key}`}
type="checkbox"
checked={enabled}
onChange={(e) => {
setForm((f) => ({
...f,
experimentalFeatures: {
...(f.experimentalFeatures ?? {}),
[key]: e.target.checked,
},
}));
}}
/>
<span>{KNOWN_EXPERIMENTAL_FEATURES[key] ?? key}</span>
</label>
))}
</div>
) : (
<div className="form-group">
<label>Feature Flags</label>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)" }}>
{featureFlags.map(([key, enabled]) => (
<label key={key} htmlFor={`experimental-${key}`} className="checkbox-label">
<input
id={`experimental-${key}`}
type="checkbox"
checked={enabled}
onChange={(e) => {
setForm((f) => ({
...f,
experimentalFeatures: {
...(f.experimentalFeatures ?? {}),
[key]: e.target.checked,
},
}));
}}
/>
<span>{key}</span>
</label>
))}
</div>
</div>
)}
</div>
</>
);
}

View File

@@ -15,6 +15,7 @@ const defaultSettings: Settings = {
worktreeInitCommand: "",
testCommand: "",
buildCommand: "",
experimentalFeatures: { insights: true, roadmap: true },
};
vi.mock("../../api", async (importOriginal) => {
@@ -1306,6 +1307,24 @@ describe("App view switching", () => {
localStorage.removeItem("kb:proj_a:kb-dashboard-task-view");
localStorage.removeItem("kb:proj_b:kb-dashboard-task-view");
});
it("does not render insights view button when insights experimental feature is disabled", async () => {
// Override fetchSettings to return insights as disabled
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { insights: false },
});
render(<App />);
// Wait for the header to render
await waitFor(() => {
expect(screen.getByTitle("Board view")).toBeTruthy();
});
// Insights button should not be rendered
expect(screen.queryByTitle("Insights view")).toBeNull();
});
});
describe("App GitHub import", () => {

View File

@@ -453,16 +453,16 @@ describe("Header", () => {
// ── Roadmaps View Toggle ───────────────────────────────────────
it("renders roadmaps view button in view toggle when onChangeView is provided", () => {
it("renders roadmaps view button in view toggle when experimentalFeatures.roadmap is true", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
render(<Header view="board" onChangeView={onChangeView} experimentalFeatures={{ roadmap: true }} />);
const roadmapsBtn = screen.getByTitle("Roadmaps view");
expect(roadmapsBtn).toBeDefined();
});
it("calls onChangeView with 'roadmaps' when roadmaps view button is clicked", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
render(<Header view="board" onChangeView={onChangeView} experimentalFeatures={{ roadmap: true }} />);
const roadmapsBtn = screen.getByTitle("Roadmaps view");
fireEvent.click(roadmapsBtn);
expect(onChangeView).toHaveBeenCalledWith("roadmaps");
@@ -470,7 +470,7 @@ describe("Header", () => {
it("marks roadmaps view button as active when view is 'roadmaps'", () => {
const onChangeView = vi.fn();
render(<Header view="roadmaps" onChangeView={onChangeView} />);
render(<Header view="roadmaps" onChangeView={onChangeView} experimentalFeatures={{ roadmap: true }} />);
const roadmapsBtn = screen.getByTitle("Roadmaps view");
expect(roadmapsBtn.className).toContain("active");
expect(roadmapsBtn.getAttribute("aria-pressed")).toBe("true");
@@ -478,24 +478,36 @@ describe("Header", () => {
it("does not mark roadmaps view button as active when view is 'board'", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
render(<Header view="board" onChangeView={onChangeView} experimentalFeatures={{ roadmap: true }} />);
const roadmapsBtn = screen.getByTitle("Roadmaps view");
expect(roadmapsBtn.className).not.toContain("active");
expect(roadmapsBtn.getAttribute("aria-pressed")).toBe("false");
});
// ── Insights View Toggle ─────────────────────────────────────────
it("does not render roadmaps view button when experimentalFeatures.roadmap is false", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} experimentalFeatures={{ roadmap: false }} />);
expect(screen.queryByTitle("Roadmaps view")).toBeNull();
});
it("renders insights view button in view toggle when onChangeView is provided", () => {
it("does not render roadmaps view button when experimentalFeatures is not provided", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
expect(screen.queryByTitle("Roadmaps view")).toBeNull();
});
// ── Insights View Toggle ─────────────────────────────────────────
it("renders insights view button in view toggle when experimentalFeatures.insights is true", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} experimentalFeatures={{ insights: true }} />);
const insightsBtn = screen.getByTitle("Insights view");
expect(insightsBtn).toBeDefined();
});
it("calls onChangeView with 'insights' when insights view button is clicked", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
render(<Header view="board" onChangeView={onChangeView} experimentalFeatures={{ insights: true }} />);
const insightsBtn = screen.getByTitle("Insights view");
fireEvent.click(insightsBtn);
expect(onChangeView).toHaveBeenCalledWith("insights");
@@ -503,7 +515,7 @@ describe("Header", () => {
it("marks insights view button as active when view is 'insights'", () => {
const onChangeView = vi.fn();
render(<Header view="insights" onChangeView={onChangeView} />);
render(<Header view="insights" onChangeView={onChangeView} experimentalFeatures={{ insights: true }} />);
const insightsBtn = screen.getByTitle("Insights view");
expect(insightsBtn.className).toContain("active");
expect(insightsBtn.getAttribute("aria-pressed")).toBe("true");
@@ -511,12 +523,24 @@ describe("Header", () => {
it("does not mark insights view button as active when view is 'board'", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
render(<Header view="board" onChangeView={onChangeView} experimentalFeatures={{ insights: true }} />);
const insightsBtn = screen.getByTitle("Insights view");
expect(insightsBtn.className).not.toContain("active");
expect(insightsBtn.getAttribute("aria-pressed")).toBe("false");
});
it("does not render insights view button when experimentalFeatures.insights is false", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} experimentalFeatures={{ insights: false }} />);
expect(screen.queryByTitle("Insights view")).toBeNull();
});
it("does not render insights view button when experimentalFeatures is not provided", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
expect(screen.queryByTitle("Insights view")).toBeNull();
});
// ── Search Visibility by View ─────────────────────────────────────
it("shows search toggle when view is 'board' on desktop", () => {

View File

@@ -56,7 +56,7 @@ describe("MobileNavBar", () => {
mockViewport("mobile");
});
it("renders eight tab buttons (tasks + agents + missions + chat + mailbox + skills + roadmaps + more) when showSkillsTab is true", () => {
it("renders seven tab buttons (tasks + agents + missions + chat + mailbox + skills + more) when showSkillsTab is true", () => {
render(<MobileNavBar {...createDefaultProps()} showSkillsTab={true} />);
expect(screen.getByTestId("mobile-nav-tab-tasks")).toBeDefined();
@@ -65,10 +65,15 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-nav-tab-chat")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-mailbox")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-roadmaps")).toBeDefined();
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
});
it("renders roadmaps tab when experimentalFeatures.roadmap is true", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ roadmap: true }} />);
expect(screen.getByTestId("mobile-nav-tab-roadmaps")).toBeDefined();
});
it("does not render skills tab when showSkillsTab is false", () => {
render(<MobileNavBar {...createDefaultProps()} showSkillsTab={false} />);
expect(screen.queryByTestId("mobile-nav-tab-skills")).toBeNull();
@@ -206,14 +211,26 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-more-item-usage")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-projects")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-chat")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-roadmaps")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-insights")).toBeDefined();
expect(screen.queryByTestId("mobile-more-item-roadmaps")).toBeNull();
expect(screen.queryByTestId("mobile-more-item-insights")).toBeNull();
expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined();
});
it("shows roadmaps in more sheet when experimentalFeatures.roadmap is true", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ roadmap: true }} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.getByTestId("mobile-more-item-roadmaps")).toBeDefined();
});
it("shows insights in more sheet when experimentalFeatures.insights is true", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ insights: true }} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.getByTestId("mobile-more-item-insights")).toBeDefined();
});
it("insights item in more sheet calls onChangeView with 'insights'", () => {
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);
const { container } = render(<MobileNavBar {...props} experimentalFeatures={{ insights: true }} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-item-insights"));

View File

@@ -14,6 +14,8 @@ export interface UseAppSettingsResult {
showQuickChatFAB: boolean;
githubTokenConfigured: boolean;
experimentalFeatures: Record<string, boolean>;
insightsEnabled: boolean;
roadmapEnabled: boolean;
toggleAutoMerge: () => Promise<void>;
toggleGlobalPause: () => Promise<void>;
toggleEnginePause: () => Promise<void>;
@@ -35,6 +37,8 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [showQuickChatFAB, setShowQuickChatFAB] = useState(false);
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
const [experimentalFeatures, setExperimentalFeatures] = useState<Record<string, boolean>>({});
const [insightsEnabled, setInsightsEnabled] = useState(false);
const [roadmapEnabled, setRoadmapEnabled] = useState(false);
/**
* Fetches config and settings from the backend and updates local state.
@@ -60,6 +64,9 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setTaskStuckTimeoutMs(settings.taskStuckTimeoutMs);
setShowQuickChatFAB(settings.showQuickChatFAB === true);
setExperimentalFeatures(settings.experimentalFeatures ?? {});
const features = settings.experimentalFeatures ?? {};
setInsightsEnabled(features.insights === true);
setRoadmapEnabled(features.roadmap === true);
}
}, [projectId]);
@@ -121,6 +128,8 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
showQuickChatFAB,
githubTokenConfigured,
experimentalFeatures,
insightsEnabled,
roadmapEnabled,
toggleAutoMerge,
toggleGlobalPause,
toggleEnginePause,