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:
@@ -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" && (
|
||||
|
||||
@@ -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}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()} />);
|
||||
|
||||
Reference in New Issue
Block a user