feat(FN-2031): gate skills view behind experimental feature flag

- Read experimentalFeatures from app settings and derive skillsView enablement
- Hide Skills view toggles in Header and MobileNavBar unless showSkillsTab is enabled
- Prevent direct navigation to skills view by redirecting disabled sessions back to board
- Update Header and MobileNavBar tests to cover enabled, disabled, and default-hidden skills tab behavior
This commit is contained in:
Fusion
2026-04-17 16:59:09 -07:00
committed by gsxdsm
parent 0678a5cdf6
commit c6f3b8c074
6 changed files with 75 additions and 29 deletions

View File

@@ -189,11 +189,14 @@ function AppInner() {
taskStuckTimeoutMs,
showQuickChatFAB,
githubTokenConfigured,
experimentalFeatures,
toggleAutoMerge,
toggleGlobalPause,
toggleEnginePause,
refresh: refreshAppSettings,
} = useAppSettings(currentProject?.id);
const skillsEnabled = experimentalFeatures.skillsView === true;
const {
availableModels,
favoriteProviders,
@@ -344,6 +347,11 @@ function AppInner() {
// Project view
if (taskView === "skills") {
if (!skillsEnabled) {
// Redirect to board if skills view is not enabled
handleChangeTaskView("board");
return null;
}
return (
<PageErrorBoundary>
<SkillsView
@@ -551,6 +559,7 @@ function AppInner() {
onToggleEnginePause={toggleEnginePause}
view={taskView}
onChangeView={viewMode === "project" && currentProject ? handleTaskViewChange : undefined}
showSkillsTab={skillsEnabled}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
projects={effectiveProjects}
@@ -630,6 +639,7 @@ function AppInner() {
onRunScript={modalManager.runScript}
onOpenQuickChat={() => setQuickChatOpen(true)}
projectId={currentProject?.id}
showSkillsTab={skillsEnabled}
/>
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && (
<QuickChatFAB

View File

@@ -183,6 +183,8 @@ export interface HeaderProps {
onToggleEnginePause?: () => void;
view?: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights";
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;
searchQuery?: string;
onSearchChange?: (query: string) => void;
/** Multi-project props */
@@ -229,6 +231,7 @@ export function Header({
onToggleEnginePause,
view = "board",
onChangeView,
showSkillsTab,
searchQuery = "",
onSearchChange,
projects = [],
@@ -730,15 +733,17 @@ export function Header({
>
<Mail size={16} />
</button>
<button
className={`view-toggle-btn${view === "skills" ? " active" : ""}`}
onClick={() => onChangeView("skills")}
title="Skills view"
aria-label="Skills view"
aria-pressed={view === "skills"}
>
<Zap size={16} />
</button>
{showSkillsTab && (
<button
className={`view-toggle-btn${view === "skills" ? " active" : ""}`}
onClick={() => onChangeView("skills")}
title="Skills view"
aria-label="Skills view"
aria-pressed={view === "skills"}
>
<Zap size={16} />
</button>
)}
<button
className={`view-toggle-btn${view === "roadmaps" ? " active" : ""}`}
onClick={() => onChangeView("roadmaps")}

View File

@@ -56,6 +56,8 @@ export interface MobileNavBarProps {
onOpenQuickChat?: () => void;
projectId?: string;
onViewAllProjects?: () => void;
/** Whether to show the skills tab */
showSkillsTab?: boolean;
}
function GitHubLogo({ size = 20 }: { size?: number }) {
@@ -100,6 +102,7 @@ export function MobileNavBar({
onOpenQuickChat,
projectId,
onViewAllProjects,
showSkillsTab,
}: MobileNavBarProps) {
const mode = useViewportMode();
const [isMoreOpen, setIsMoreOpen] = useState(false);
@@ -241,17 +244,19 @@ export function MobileNavBar({
)}
</button>
<button
type="button"
className={`mobile-nav-tab${view === "skills" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-skills"
role="tab"
aria-selected={view === "skills"}
onClick={() => onChangeView("skills")}
>
<Zap />
<span className="mobile-nav-tab-label">Skills</span>
</button>
{showSkillsTab && (
<button
type="button"
className={`mobile-nav-tab${view === "skills" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-skills"
role="tab"
aria-selected={view === "skills"}
onClick={() => onChangeView("skills")}
>
<Zap />
<span className="mobile-nav-tab-label">Skills</span>
</button>
)}
<button
type="button"

View File

@@ -344,14 +344,14 @@ describe("Header", () => {
it("renders skills 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} showSkillsTab={true} />);
const skillsBtn = screen.getByTitle("Skills view");
expect(skillsBtn).toBeDefined();
});
it("calls onChangeView with 'skills' when skills view button is clicked", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
render(<Header view="board" onChangeView={onChangeView} showSkillsTab={true} />);
const skillsBtn = screen.getByTitle("Skills view");
fireEvent.click(skillsBtn);
expect(onChangeView).toHaveBeenCalledWith("skills");
@@ -359,7 +359,7 @@ describe("Header", () => {
it("marks skills view button as active when view is 'skills'", () => {
const onChangeView = vi.fn();
render(<Header view="skills" onChangeView={onChangeView} />);
render(<Header view="skills" onChangeView={onChangeView} showSkillsTab={true} />);
const skillsBtn = screen.getByTitle("Skills view");
expect(skillsBtn.className).toContain("active");
expect(skillsBtn.getAttribute("aria-pressed")).toBe("true");
@@ -367,12 +367,24 @@ describe("Header", () => {
it("does not mark skills view button as active when view is 'board'", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
render(<Header view="board" onChangeView={onChangeView} showSkillsTab={true} />);
const skillsBtn = screen.getByTitle("Skills view");
expect(skillsBtn.className).not.toContain("active");
expect(skillsBtn.getAttribute("aria-pressed")).toBe("false");
});
it("does not render skills view button when showSkillsTab is false", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} showSkillsTab={false} />);
expect(screen.queryByTitle("Skills view")).toBeNull();
});
it("does not render skills view button when showSkillsTab is omitted", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
expect(screen.queryByTitle("Skills view")).toBeNull();
});
// ── Chat View Toggle ─────────────────────────────────────────
it("renders chat view button in view toggle when onChangeView is provided", () => {

View File

@@ -56,8 +56,8 @@ describe("MobileNavBar", () => {
mockViewport("mobile");
});
it("renders eight tab buttons (tasks + agents + missions + chat + mailbox + skills + roadmaps + more)", () => {
render(<MobileNavBar {...createDefaultProps()} />);
it("renders eight tab buttons (tasks + agents + missions + chat + mailbox + skills + roadmaps + more) when showSkillsTab is true", () => {
render(<MobileNavBar {...createDefaultProps()} showSkillsTab={true} />);
expect(screen.getByTestId("mobile-nav-tab-tasks")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-agents")).toBeDefined();
@@ -69,6 +69,16 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
});
it("does not render skills tab when showSkillsTab is false", () => {
render(<MobileNavBar {...createDefaultProps()} showSkillsTab={false} />);
expect(screen.queryByTestId("mobile-nav-tab-skills")).toBeNull();
});
it("does not render skills tab when showSkillsTab is omitted", () => {
render(<MobileNavBar {...createDefaultProps()} />);
expect(screen.queryByTestId("mobile-nav-tab-skills")).toBeNull();
});
it("active tab is highlighted for mailbox", () => {
render(<MobileNavBar {...createDefaultProps()} view="mailbox" />);
expect(screen.getByTestId("mobile-nav-tab-mailbox").className).toContain("mobile-nav-tab--active");
@@ -153,19 +163,19 @@ describe("MobileNavBar", () => {
it("skills tab calls onChangeView with 'skills'", () => {
const props = createDefaultProps();
render(<MobileNavBar {...props} view="board" />);
render(<MobileNavBar {...props} view="board" showSkillsTab={true} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-skills"));
expect(props.onChangeView).toHaveBeenCalledWith("skills");
});
it("skills tab is active when view is 'skills'", () => {
render(<MobileNavBar {...createDefaultProps()} view="skills" />);
render(<MobileNavBar {...createDefaultProps()} view="skills" showSkillsTab={true} />);
expect(screen.getByTestId("mobile-nav-tab-skills").className).toContain("mobile-nav-tab--active");
});
it("skills tab is not active when view is 'board'", () => {
render(<MobileNavBar {...createDefaultProps()} view="board" />);
render(<MobileNavBar {...createDefaultProps()} view="board" showSkillsTab={true} />);
expect(screen.getByTestId("mobile-nav-tab-skills").className).not.toContain("mobile-nav-tab--active");
});

View File

@@ -13,6 +13,7 @@ export interface UseAppSettingsResult {
taskStuckTimeoutMs: number | undefined;
showQuickChatFAB: boolean;
githubTokenConfigured: boolean;
experimentalFeatures: Record<string, boolean>;
toggleAutoMerge: () => Promise<void>;
toggleGlobalPause: () => Promise<void>;
toggleEnginePause: () => Promise<void>;
@@ -33,6 +34,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [taskStuckTimeoutMs, setTaskStuckTimeoutMs] = useState<number | undefined>(undefined);
const [showQuickChatFAB, setShowQuickChatFAB] = useState(false);
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
const [experimentalFeatures, setExperimentalFeatures] = useState<Record<string, boolean>>({});
/**
* Fetches config and settings from the backend and updates local state.
@@ -57,6 +59,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setGithubTokenConfigured(Boolean(settings.githubTokenConfigured));
setTaskStuckTimeoutMs(settings.taskStuckTimeoutMs);
setShowQuickChatFAB(settings.showQuickChatFAB === true);
setExperimentalFeatures(settings.experimentalFeatures ?? {});
}
}, [projectId]);
@@ -117,6 +120,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
taskStuckTimeoutMs,
showQuickChatFAB,
githubTokenConfigured,
experimentalFeatures,
toggleAutoMerge,
toggleGlobalPause,
toggleEnginePause,