feat(FN-2695): gate Todos view behind todoView experimental flag

- Add todoView to experimental feature settings and expose it in Settings modal and docs
- Derive todosEnabled in app settings hook with regression tests for enabled/disabled behavior
- Gate App routing so Todos only renders when the flag is enabled and redirects otherwise
- Hide Todos entries in header overflow and mobile navigation when the flag is off
This commit is contained in:
Fusion
2026-04-27 08:48:34 -07:00
committed by gsxdsm
parent 92b94594f3
commit 5681378c79
8 changed files with 72 additions and 37 deletions

View File

@@ -666,6 +666,7 @@ Common built-in dashboard flags include:
- `skillsView`
- `nodesView`
- `devServerView`
- `todoView`
---

View File

@@ -341,6 +341,7 @@ function AppInner() {
roadmapEnabled,
memoryEnabled,
devServerEnabled,
todosEnabled,
toggleAutoMerge,
toggleGlobalPause,
toggleEnginePause,
@@ -370,7 +371,10 @@ function AppInner() {
if ((taskView === "devserver" || taskView === "dev-server") && !devServerEnabled) {
handleChangeTaskView("board");
}
}, [taskView, insightsEnabled, roadmapEnabled, experimentalFeatures, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled]);
if (taskView === "todos" && !todosEnabled) {
handleChangeTaskView("board");
}
}, [taskView, insightsEnabled, roadmapEnabled, experimentalFeatures, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, todosEnabled]);
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
useEffect(() => {
@@ -841,6 +845,7 @@ function AppInner() {
memoryView: memoryEnabled,
devServer: devServerEnabled,
devServerView: devServerEnabled,
todoView: todosEnabled,
}}
/>
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && (
@@ -925,6 +930,7 @@ function AppInner() {
memoryView: memoryEnabled,
devServer: devServerEnabled,
devServerView: devServerEnabled,
todoView: todosEnabled,
}}
/>
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && (

View File

@@ -217,7 +217,7 @@ export interface HeaderProps {
/** Whether the current view is a remote node */
isRemote?: boolean;
/** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean };
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; todoView?: boolean };
}
export function Header({
@@ -325,17 +325,15 @@ export function Header({
}, [overflowScripts]);
const hasViewOverflowItems = useMemo(() => {
const hasTodosView = Boolean(onChangeView);
return !!(
hasTodosView ||
experimentalFeatures?.todoView ||
experimentalFeatures?.insights ||
experimentalFeatures?.roadmap ||
showSkillsTab ||
experimentalFeatures?.memoryView ||
experimentalFeatures?.devServerView
);
}, [experimentalFeatures, onChangeView, showSkillsTab]);
}, [experimentalFeatures, showSkillsTab]);
const getEffectiveViewport = useCallback(() => {
const vv = window.visualViewport;
@@ -1109,7 +1107,7 @@ export function Header({
<>
<button
ref={viewOverflowTriggerRef}
className={`view-toggle-btn${["skills", "roadmaps", "insights", "memory", "dev-server", "devserver", "todos"].includes(view) ? " active" : ""}`}
className={`view-toggle-btn${["skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (experimentalFeatures?.todoView && view === "todos") ? " active" : ""}`}
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
title="More views"
aria-label="More views"
@@ -1197,18 +1195,20 @@ export function Header({
<span className="visually-hidden" data-testid="view-toggle-dev-server" />
</button>
)}
<button
className={`view-toggle-overflow-item${view === "todos" ? " active" : ""}`}
onClick={() => {
onChangeView("todos");
setIsViewOverflowOpen(false);
}}
role="menuitem"
data-testid="view-overflow-todos"
>
<CheckSquare size={14} />
<span>Todos</span>
</button>
{experimentalFeatures?.todoView && (
<button
className={`view-toggle-overflow-item${view === "todos" ? " active" : ""}`}
onClick={() => {
onChangeView("todos");
setIsViewOverflowOpen(false);
}}
role="menuitem"
data-testid="view-overflow-todos"
>
<CheckSquare size={14} />
<span>Todos</span>
</button>
)}
</div>
)}
</>

View File

@@ -63,7 +63,7 @@ export interface MobileNavBarProps {
/** Whether to show the skills tab */
showSkillsTab?: boolean;
/** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean };
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; todoView?: boolean };
}
function GitHubLogo({ size = 20 }: { size?: number }) {
@@ -176,6 +176,7 @@ export function MobileNavBar({
const roadmapEnabled = Boolean(experimentalFeatures?.roadmap);
const skillsEnabled = Boolean(showSkillsTab);
const todoViewEnabled = Boolean(experimentalFeatures?.todoView);
// Keep a maximum of one optional primary tab visible at once to preserve touch-target width.
// Overflowed destinations remain available in the More sheet.
@@ -189,7 +190,7 @@ export function MobileNavBar({
|| view === "memory"
|| view === "devserver"
|| view === "dev-server"
|| view === "todos"
|| (view === "todos" && todoViewEnabled)
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|| (view === "skills" && !showSkillsTopLevel);
@@ -596,15 +597,17 @@ export function MobileNavBar({
</button>
)}
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-todos"
onClick={() => handleMoreAction(() => onChangeView("todos"))}
>
<CheckSquare size={14} />
<span>Todos</span>
</button>
{todoViewEnabled && (
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-todos"
onClick={() => handleMoreAction(() => onChangeView("todos"))}
>
<CheckSquare size={14} />
<span>Todos</span>
</button>
)}
<div className="mobile-more-separator" />

View File

@@ -107,6 +107,7 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
skillsView: "Skills View",
nodesView: "Nodes View",
devServerView: "Dev Server",
todoView: "Todo List",
};
const EXPERIMENTAL_FEATURE_LEGACY_ALIASES: Record<string, string> = {

View File

@@ -146,13 +146,13 @@ describe("Header", () => {
expect(listBtn.getAttribute("aria-pressed")).toBe("false");
});
it("renders view overflow trigger so Todos remains reachable", () => {
renderHeader({ onChangeView: noop });
it("renders view overflow trigger when todoView experimental flag is enabled", () => {
renderHeader({ onChangeView: noop, experimentalFeatures: { todoView: true } });
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
});
it("shows the Todos entry in view overflow", () => {
renderHeader({ onChangeView: noop });
it("shows the Todos entry in view overflow when todoView is enabled", () => {
renderHeader({ onChangeView: noop, experimentalFeatures: { todoView: true } });
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
});
@@ -167,13 +167,13 @@ describe("Header", () => {
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
});
it("still renders view overflow trigger when overflow feature flags are explicitly false", () => {
it("does not render view overflow trigger when all overflow feature flags are false", () => {
renderHeader({
onChangeView: noop,
showSkillsTab: false,
experimentalFeatures: { insights: false, roadmap: false, memoryView: false },
experimentalFeatures: { insights: false, roadmap: false, memoryView: false, devServerView: false, todoView: false },
});
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
expect(screen.queryByTestId("view-toggle-overflow-trigger")).toBeNull();
});
});

View File

@@ -179,4 +179,24 @@ describe("useAppSettings", () => {
expect(result.current.devServerEnabled).toBe(true);
});
});
it("derives todosEnabled from experimentalFeatures.todoView", async () => {
mockFetchSettings.mockResolvedValueOnce({
autoMerge: false,
globalPause: false,
enginePaused: false,
prAuthAvailable: true,
taskStuckTimeoutMs: 600000,
showQuickChatFAB: false,
experimentalFeatures: {
todoView: true,
},
} as never);
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.todosEnabled).toBe(true);
});
});
});

View File

@@ -18,6 +18,7 @@ export interface UseAppSettingsResult {
roadmapEnabled: boolean;
memoryEnabled: boolean;
devServerEnabled: boolean;
todosEnabled: boolean;
toggleAutoMerge: () => Promise<void>;
toggleGlobalPause: () => Promise<void>;
toggleEnginePause: () => Promise<void>;
@@ -43,6 +44,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [roadmapEnabled, setRoadmapEnabled] = useState(false);
const [memoryEnabled, setMemoryEnabled] = useState(false);
const [devServerEnabled, setDevServerEnabled] = useState(false);
const [todosEnabled, setTodosEnabled] = useState(false);
/**
* Fetches config and settings from the backend and updates local state.
@@ -73,6 +75,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setRoadmapEnabled(features.roadmap === true);
setMemoryEnabled(features.memoryView === true);
setDevServerEnabled(features.devServerView === true || features.devServer === true);
setTodosEnabled(features.todoView === true);
}
}, [projectId]);
@@ -144,6 +147,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
roadmapEnabled,
memoryEnabled,
devServerEnabled,
todosEnabled,
toggleAutoMerge,
toggleGlobalPause,
toggleEnginePause,