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