feat(FN-2033): gate agents view navigation behind feature flag

- Wire App.tsx to read experimentalFeatures.agentsView, hide agents view when disabled, and redirect agents taskView back to board
- Add showAgentsTab prop to Header and MobileNavBar and conditionally render agents navigation controls only when enabled
- Update App, Header, and MobileNavBar tests to cover enabled/disabled agents tab behavior and active-state interactions
- Adjust mobile and tablet regression tests to pass showAgentsTab where agents controls are expected
This commit is contained in:
Fusion
2026-04-17 19:17:24 -07:00
committed by gsxdsm
parent fc106f6e6e
commit 695d0fd858
8 changed files with 120 additions and 36 deletions

View File

@@ -200,6 +200,7 @@ function AppInner() {
const skillsEnabled = experimentalFeatures.skillsView === true;
const nodesEnabled = experimentalFeatures.nodesView === true;
const agentsEnabled = experimentalFeatures.agentsView === true;
// Redirect to board if insights/roadmaps view is disabled
// Only run after settings have been loaded (experimentalFeatures is non-empty)
@@ -211,7 +212,10 @@ function AppInner() {
if (taskView === "roadmaps" && !roadmapEnabled) {
handleChangeTaskView("board");
}
}, [taskView, insightsEnabled, roadmapEnabled, experimentalFeatures, handleChangeTaskView]);
if (taskView === "agents" && !agentsEnabled) {
handleChangeTaskView("board");
}
}, [taskView, insightsEnabled, roadmapEnabled, experimentalFeatures, handleChangeTaskView, agentsEnabled]);
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
useEffect(() => {
@@ -441,7 +445,7 @@ function AppInner() {
);
}
if (taskView === "agents") {
if (taskView === "agents" && agentsEnabled) {
return (
<PageErrorBoundary>
<AgentsView addToast={addToast} projectId={currentProject?.id} />
@@ -584,6 +588,7 @@ function AppInner() {
view={taskView}
onChangeView={viewMode === "project" && currentProject ? handleTaskViewChange : undefined}
showSkillsTab={skillsEnabled}
showAgentsTab={agentsEnabled}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
projects={effectiveProjects}
@@ -665,6 +670,7 @@ function AppInner() {
onOpenQuickChat={() => setQuickChatOpen(true)}
projectId={currentProject?.id}
showSkillsTab={skillsEnabled}
showAgentsTab={agentsEnabled}
experimentalFeatures={{ insights: insightsEnabled, roadmap: roadmapEnabled }}
/>
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && (

View File

@@ -113,7 +113,7 @@ describe("Mobile Feature Access Regression Guard", () => {
it("agents view is accessible via mobile nav bar", () => {
const props = createDefaultMobileNavProps();
render(<MobileNavBar {...props} />);
render(<MobileNavBar {...props} showAgentsTab={true} />);
const agentsTab = screen.getByTestId("mobile-nav-tab-agents");
expect(agentsTab).toBeDefined();
@@ -197,6 +197,7 @@ describe("Mobile Feature Access Regression Guard", () => {
view="board"
onChangeView={vi.fn()}
mobileNavEnabled={false}
showAgentsTab={true}
/>,
);
@@ -211,6 +212,7 @@ describe("Mobile Feature Access Regression Guard", () => {
<MobileNavBar
{...createDefaultMobileNavProps()}
onChangeView={mobileNavOnChangeView}
showAgentsTab={true}
/>,
);

View File

@@ -94,7 +94,7 @@ describe("tablet header controls", () => {
});
it("renders view toggle inline on tablet", () => {
renderTabletHeader({ onChangeView: noop });
renderTabletHeader({ onChangeView: noop, showAgentsTab: true });
expect(screen.getByTitle("Board view")).toBeDefined();
expect(screen.getByTitle("List view")).toBeDefined();
expect(screen.getByTitle("Agents view")).toBeDefined();

View File

@@ -187,6 +187,8 @@ export interface HeaderProps {
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights") => void;
/** Whether to show the skills tab in the view toggle */
showSkillsTab?: boolean;
/** When true, shows the Agents view tab button. Hidden by default (experimental feature). */
showAgentsTab?: boolean;
searchQuery?: string;
onSearchChange?: (query: string) => void;
/** Multi-project props */
@@ -237,6 +239,7 @@ export function Header({
view = "board",
onChangeView,
showSkillsTab,
showAgentsTab,
searchQuery = "",
onSearchChange,
projects = [],
@@ -694,15 +697,17 @@ export function Header({
>
<List size={16} />
</button>
<button
className={`view-toggle-btn${view === "agents" ? " active" : ""}`}
onClick={() => onChangeView("agents")}
title="Agents view"
aria-label="Agents view"
aria-pressed={view === "agents"}
>
<Bot size={16} />
</button>
{showAgentsTab && (
<button
className={`view-toggle-btn${view === "agents" ? " active" : ""}`}
onClick={() => onChangeView("agents")}
title="Agents view"
aria-label="Agents view"
aria-pressed={view === "agents"}
>
<Bot size={16} />
</button>
)}
<button
className={`view-toggle-btn${view === "missions" ? " active" : ""}`}
onClick={() => onChangeView("missions")}

View File

@@ -58,6 +58,8 @@ export interface MobileNavBarProps {
onViewAllProjects?: () => void;
/** Whether to show the skills tab */
showSkillsTab?: boolean;
/** When true, shows the Agents mobile tab. Hidden by default (experimental feature). */
showAgentsTab?: boolean;
/** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: { insights?: boolean; roadmap?: boolean };
}
@@ -105,6 +107,7 @@ export function MobileNavBar({
projectId,
onViewAllProjects,
showSkillsTab,
showAgentsTab,
experimentalFeatures,
}: MobileNavBarProps) {
const mode = useViewportMode();
@@ -196,17 +199,19 @@ export function MobileNavBar({
<span className="mobile-nav-tab-label">Tasks</span>
</button>
<button
type="button"
className={`mobile-nav-tab${view === "agents" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-agents"
role="tab"
aria-selected={view === "agents"}
onClick={() => onChangeView("agents")}
>
<Bot />
<span className="mobile-nav-tab-label">Agents</span>
</button>
{showAgentsTab && (
<button
type="button"
className={`mobile-nav-tab${view === "agents" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-agents"
role="tab"
aria-selected={view === "agents"}
onClick={() => onChangeView("agents")}
>
<Bot />
<span className="mobile-nav-tab-label">Agents</span>
</button>
)}
<button
type="button"

View File

@@ -15,7 +15,7 @@ const defaultSettings: Settings = {
worktreeInitCommand: "",
testCommand: "",
buildCommand: "",
experimentalFeatures: { insights: true, roadmap: true },
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true },
};
vi.mock("../../api", async (importOriginal) => {
@@ -1228,6 +1228,25 @@ describe("App view switching", () => {
localStorage.removeItem(taskViewStorageKey());
});
it("does not render agents view button when agentsView experimental feature is disabled", async () => {
// Override the default mock to exclude agentsView
vi.mocked(fetchSettings).mockResolvedValue({
...defaultSettings,
experimentalFeatures: { insights: true, roadmap: true, skillsView: true }, // no agentsView
});
render(<App />);
await waitFor(() => {
expect(screen.getByTitle("Board view")).toBeTruthy();
});
expect(screen.queryByTitle("Agents view")).toBeNull();
// Cleanup: restore default mock
vi.mocked(fetchSettings).mockResolvedValue({ ...defaultSettings });
});
// ── Insights View ──────────────────────────────────────────────────
it("renders InsightsView when insights view is selected", async () => {

View File

@@ -270,14 +270,14 @@ describe("Header", () => {
it("renders agents view button in view toggle when onChangeView is provided", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
render(<Header view="board" onChangeView={onChangeView} showAgentsTab={true} />);
const agentsBtn = screen.getByTitle("Agents view");
expect(agentsBtn).toBeDefined();
});
it("calls onChangeView with 'agents' when agents view button is clicked", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
render(<Header view="board" onChangeView={onChangeView} showAgentsTab={true} />);
const agentsBtn = screen.getByTitle("Agents view");
fireEvent.click(agentsBtn);
expect(onChangeView).toHaveBeenCalledWith("agents");
@@ -285,7 +285,7 @@ describe("Header", () => {
it("marks agents view button as active when view is 'agents'", () => {
const onChangeView = vi.fn();
render(<Header view="agents" onChangeView={onChangeView} />);
render(<Header view="agents" onChangeView={onChangeView} showAgentsTab={true} />);
const agentsBtn = screen.getByTitle("Agents view");
expect(agentsBtn.className).toContain("active");
expect(agentsBtn.getAttribute("aria-pressed")).toBe("true");
@@ -293,7 +293,7 @@ describe("Header", () => {
it("does not mark agents view button as active when view is 'board'", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
render(<Header view="board" onChangeView={onChangeView} showAgentsTab={true} />);
const agentsBtn = screen.getByTitle("Agents view");
expect(agentsBtn.className).not.toContain("active");
expect(agentsBtn.getAttribute("aria-pressed")).toBe("false");
@@ -301,12 +301,30 @@ describe("Header", () => {
it("does not mark board view button as active when view is 'agents'", () => {
const onChangeView = vi.fn();
render(<Header view="agents" onChangeView={onChangeView} />);
render(<Header view="agents" onChangeView={onChangeView} showAgentsTab={true} />);
const boardBtn = screen.getByTitle("Board view");
expect(boardBtn.className).not.toContain("active");
expect(boardBtn.getAttribute("aria-pressed")).toBe("false");
});
it("hides agents view button when showAgentsTab is false", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} showAgentsTab={false} />);
expect(screen.queryByTitle("Agents view")).toBeNull();
});
it("hides agents view button when showAgentsTab is not provided", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
expect(screen.queryByTitle("Agents view")).toBeNull();
});
it("shows agents view button when showAgentsTab is true", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} showAgentsTab={true} />);
expect(screen.getByTitle("Agents view")).toBeDefined();
});
// ── Missions View Toggle ────────────────────────────────────────
it("renders missions view button in view toggle when onChangeView is provided", () => {
@@ -657,6 +675,7 @@ describe("Header", () => {
onChangeView={vi.fn()}
searchQuery=""
onSearchChange={onSearchChange}
showAgentsTab={true}
/>
);
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
@@ -1005,7 +1024,7 @@ describe("Header", () => {
it("hides desktop view toggle when mobileNavEnabled is true (mobile view toggle shown separately)", () => {
// When mobileNavEnabled, the full desktop-style view toggle (with agents, missions, etc.)
// should be hidden. Instead, a compact board/list-only toggle appears via mobile-view-toggle.
render(<Header view="board" onChangeView={vi.fn()} mobileNavEnabled={true} />);
render(<Header view="board" onChangeView={vi.fn()} mobileNavEnabled={true} showAgentsTab={true} />);
// The full desktop toggle is hidden
expect(screen.queryByTitle("Agents view")).toBeNull();
expect(screen.queryByTitle("Missions view")).toBeNull();

View File

@@ -56,9 +56,22 @@ describe("MobileNavBar", () => {
mockViewport("mobile");
});
it("renders seven tab buttons (tasks + agents + missions + chat + mailbox + skills + more) when showSkillsTab is true", () => {
it("renders six tab buttons (tasks + missions + chat + mailbox + skills + more) when showSkillsTab is true and agents is hidden by default", () => {
render(<MobileNavBar {...createDefaultProps()} showSkillsTab={true} />);
expect(screen.getByTestId("mobile-nav-tab-tasks")).toBeDefined();
expect(screen.queryByTestId("mobile-nav-tab-agents")).toBeNull();
expect(screen.getByTestId("mobile-nav-tab-missions")).toBeDefined();
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.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
});
it("renders seven tab buttons when showSkillsTab and showAgentsTab are both true", () => {
render(<MobileNavBar {...createDefaultProps()} showSkillsTab={true} showAgentsTab={true} />);
expect(screen.getByTestId("mobile-nav-tab-tasks")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-agents")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-missions")).toBeDefined();
@@ -104,14 +117,14 @@ describe("MobileNavBar", () => {
});
it("active tab is highlighted for agents", () => {
render(<MobileNavBar {...createDefaultProps()} view="agents" />);
render(<MobileNavBar {...createDefaultProps()} view="agents" showAgentsTab={true} />);
expect(screen.getByTestId("mobile-nav-tab-agents").className).toContain("mobile-nav-tab--active");
});
it("tasks tab calls onChangeView with 'board' when coming from a non-tasks view", () => {
const props = createDefaultProps();
render(<MobileNavBar {...props} view="agents" />);
render(<MobileNavBar {...props} view="agents" showAgentsTab={true} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-tasks"));
expect(props.onChangeView).toHaveBeenCalledWith("board");
@@ -144,7 +157,7 @@ describe("MobileNavBar", () => {
});
it("tasks tab is not active when view is 'agents'", () => {
render(<MobileNavBar {...createDefaultProps()} view="agents" />);
render(<MobileNavBar {...createDefaultProps()} view="agents" showAgentsTab={true} />);
expect(screen.getByTestId("mobile-nav-tab-tasks").className).not.toContain("mobile-nav-tab--active");
});
@@ -312,7 +325,7 @@ describe("MobileNavBar", () => {
it("tab click calls onChangeView", () => {
const props = createDefaultProps();
render(<MobileNavBar {...props} />);
render(<MobileNavBar {...props} showAgentsTab={true} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-agents"));
expect(props.onChangeView).toHaveBeenCalledWith("agents");
@@ -323,6 +336,21 @@ describe("MobileNavBar", () => {
expect(container.querySelector(".mobile-nav-bar--with-footer")).not.toBeNull();
});
it("hides agents tab when showAgentsTab is false", () => {
render(<MobileNavBar {...createDefaultProps()} showAgentsTab={false} />);
expect(screen.queryByTestId("mobile-nav-tab-agents")).toBeNull();
});
it("hides agents tab by default when showAgentsTab is not provided", () => {
render(<MobileNavBar {...createDefaultProps()} />);
expect(screen.queryByTestId("mobile-nav-tab-agents")).toBeNull();
});
it("shows agents tab when showAgentsTab is true", () => {
render(<MobileNavBar {...createDefaultProps()} showAgentsTab={true} />);
expect(screen.getByTestId("mobile-nav-tab-agents")).toBeDefined();
});
it("returns null on desktop viewport", () => {
mockViewport("desktop");
const { container } = render(<MobileNavBar {...createDefaultProps()} />);