feat: add feature flags system with tiered gating

- Add features.json manifest at repo root (single source of truth)
- Implement useFeatureEnabled hook + FeatureGate component
- Add ExperimentalFeaturesCard settings panel with opt-in toggles
- Gate sidebar items: managed-agents, projects, workflows
- Gate settings sections via featureGate descriptor field
- Three tiers: stable (always on), experimental (opt-in), dev (dev-only)
- Global 'Show developer features' toggle for dev tier suppression
- localStorage persistence for user overrides
- Designed for Flutter consumption (JSON manifest, platforms field)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-06-08 13:02:58 -07:00
parent dd08f988de
commit eb155c973f
11 changed files with 487 additions and 43 deletions
@@ -0,0 +1,88 @@
import { desktopFeatures, useFeatureToggle, useDevToggle } from "@/shared/features";
import type { FeatureDefinition } from "@/shared/features";
function FeatureRow({ feature }: { feature: FeatureDefinition }) {
const [enabled, toggle] = useFeatureToggle(feature.id);
return (
<label className="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-background/70 px-4 py-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{feature.name}</p>
<p className="text-xs text-muted-foreground">{feature.description}</p>
</div>
<input
checked={enabled}
className="h-4 w-4 accent-primary"
onChange={(e) => toggle(e.target.checked)}
type="checkbox"
/>
</label>
);
}
export function ExperimentalFeaturesCard() {
const [devEnabled, setDevEnabled] = useDevToggle();
const isDev = import.meta.env.DEV;
const experimentalFeatures = desktopFeatures.filter(
(f) => f.tier === "experimental",
);
const devFeatures = desktopFeatures.filter((f) => f.tier === "dev");
return (
<section className="min-w-0" data-testid="settings-experimental">
<div className="mb-3 min-w-0">
<h2 className="text-sm font-semibold tracking-tight">
Experimental Features
</h2>
<p className="text-sm text-muted-foreground">
These features are functional but still being refined. Enable them to
try new capabilities early.
</p>
</div>
<div className="flex flex-col gap-2">
{experimentalFeatures.map((f) => (
<FeatureRow feature={f} key={f.id} />
))}
</div>
{isDev && (
<>
<div className="mb-3 mt-6 min-w-0">
<h2 className="text-sm font-semibold tracking-tight">
Developer Features
</h2>
<p className="text-sm text-muted-foreground">
Only visible in development builds. Toggle the master switch to
hide all dev features.
</p>
</div>
<label className="mb-3 flex items-center justify-between gap-3 rounded-lg border border-primary/30 bg-primary/5 px-4 py-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Show developer features</p>
<p className="text-xs text-muted-foreground">
When off, all dev-tier features are hidden
</p>
</div>
<input
checked={devEnabled}
className="h-4 w-4 accent-primary"
onChange={(e) => setDevEnabled(e.target.checked)}
type="checkbox"
/>
</label>
{devEnabled && (
<div className="flex flex-col gap-2">
{devFeatures.map((f) => (
<FeatureRow feature={f} key={f.id} />
))}
</div>
)}
</>
)}
</section>
);
}
@@ -5,6 +5,7 @@ import {
Check,
Cpu,
Download,
FlaskConical,
Keyboard,
LayoutTemplate,
LockKeyhole,
@@ -33,6 +34,7 @@ import {
import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader";
import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard";
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard";
import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard";
import { MobilePairingCard } from "./MobilePairingCard";
@@ -44,6 +46,7 @@ import { UpdateChecker } from "../UpdateChecker";
export type SettingsSection =
| "profile"
| "notifications"
| "experimental"
| "agents"
| "channel-templates"
| "compute"
@@ -61,6 +64,8 @@ export type SettingsSectionDescriptor = {
value: SettingsSection;
label: string;
icon: LucideIcon;
/** If set, this section is only visible when the feature is enabled */
featureGate?: string;
};
export type SettingsPanelProps = {
@@ -93,20 +98,28 @@ export const settingsSections: SettingsSectionDescriptor[] = [
label: "Notifications",
icon: BellRing,
},
{
value: "experimental",
label: "Experimental",
icon: FlaskConical,
},
{
value: "agents",
label: "Agents",
icon: Bot,
featureGate: "managed-agents",
},
{
value: "channel-templates",
label: "Templates",
icon: LayoutTemplate,
featureGate: "channel-templates",
},
{
value: "compute",
label: "Compute",
icon: Cpu,
featureGate: "mesh-compute",
},
{
value: "shortcuts",
@@ -117,11 +130,13 @@ export const settingsSections: SettingsSectionDescriptor[] = [
value: "relay-members",
label: "Relay Access",
icon: LockKeyhole,
featureGate: "relay-members",
},
{
value: "custom-emoji",
label: "Custom Emoji",
icon: Smile,
featureGate: "custom-emoji",
},
{
value: "mobile",
@@ -137,6 +152,7 @@ export const settingsSections: SettingsSectionDescriptor[] = [
value: "doctor",
label: "Doctor",
icon: Stethoscope,
featureGate: "doctor",
},
];
@@ -296,6 +312,8 @@ export function renderSettingsSection(
onSetSoundEnabled={props.onSetSoundEnabled}
/>
);
case "experimental":
return <ExperimentalFeaturesCard />;
case "agents":
return <PreventSleepSettingsCard />;
case "channel-templates":
@@ -3,6 +3,9 @@ import { getVersion } from "@tauri-apps/api/app";
import { ArrowLeft } from "lucide-react";
import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks";
import { getFeature } from "@/shared/features/manifest";
import { getOverrides, getDevToggle } from "@/shared/features/store";
import { resolveEnabled } from "@/shared/features/useFeatureEnabled";
import { cn } from "@/shared/lib/cn";
import {
Sidebar,
@@ -116,7 +119,18 @@ export function SettingsView({
const myMembershipQuery = useMyRelayMembershipQuery();
const visibleSections = React.useMemo(() => {
const membership = myMembershipQuery.data;
const overrides = getOverrides();
const devToggle = getDevToggle();
return settingsSections.filter((s) => {
// Feature gate check
if (s.featureGate) {
const feature = getFeature(s.featureGate);
if (feature && !resolveEnabled(feature.tier, feature.id, overrides, devToggle)) {
return false;
}
}
// Relay members requires admin/owner role
if (s.value === "relay-members") {
return (
membership != null &&
+50 -43
View File
@@ -10,6 +10,7 @@ import {
Zap,
} from "lucide-react";
import * as React from "react";
import { FeatureGate } from "@/shared/features";
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
@@ -455,50 +456,56 @@ export function AppSidebar({
<span>Pulse</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-projects-view"
isActive={selectedView === "projects"}
onClick={onSelectProjects}
tooltip="Projects"
type="button"
>
<FolderGit2 className="h-4 w-4" />
<span>Projects</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-agents-view"
isActive={selectedView === "agents"}
onClick={onSelectAgents}
tooltip="Agents"
type="button"
>
<Bot className="h-4 w-4" />
<span>Agents</span>
</SidebarMenuButton>
{shouldShowAgentCount ? (
<SidebarMenuBadge
className="right-2 rounded-full bg-sidebar-accent/70 px-1.5 text-[11px] text-sidebar-foreground/75 peer-data-[active=true]/menu-button:bg-sidebar-active-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-active-foreground"
data-testid="sidebar-agents-count"
<FeatureGate feature="projects">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-projects-view"
isActive={selectedView === "projects"}
onClick={onSelectProjects}
tooltip="Projects"
type="button"
>
{totalAgentCount}
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-workflows-view"
isActive={selectedView === "workflows"}
onClick={onSelectWorkflows}
tooltip="Workflows"
type="button"
>
<Zap className="h-4 w-4" />
<span>Workflows</span>
</SidebarMenuButton>
</SidebarMenuItem>
<FolderGit2 className="h-4 w-4" />
<span>Projects</span>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
<FeatureGate feature="managed-agents">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-agents-view"
isActive={selectedView === "agents"}
onClick={onSelectAgents}
tooltip="Agents"
type="button"
>
<Bot className="h-4 w-4" />
<span>Agents</span>
</SidebarMenuButton>
{shouldShowAgentCount ? (
<SidebarMenuBadge
className="right-2 rounded-full bg-sidebar-accent/70 px-1.5 text-[11px] text-sidebar-foreground/75 peer-data-[active=true]/menu-button:bg-sidebar-active-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-active-foreground"
data-testid="sidebar-agents-count"
>
{totalAgentCount}
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
</FeatureGate>
<FeatureGate feature="workflows">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-workflows-view"
isActive={selectedView === "workflows"}
onClick={onSelectWorkflows}
tooltip="Workflows"
type="button"
>
<Zap className="h-4 w-4" />
<span>Workflows</span>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
</SidebarMenu>
</SidebarHeader>
@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import { useFeatureEnabled } from "./useFeatureEnabled";
interface FeatureGateProps {
/** The feature id from the manifest */
feature: string;
/** Content to render when the feature is enabled */
children: ReactNode;
/** Optional fallback when the feature is disabled */
fallback?: ReactNode;
}
/**
* Conditionally renders children based on whether a feature is enabled.
*
* Usage:
* <FeatureGate feature="workflows">
* <WorkflowsPanel />
* </FeatureGate>
*/
export function FeatureGate({
feature,
children,
fallback = null,
}: FeatureGateProps): ReactNode {
const enabled = useFeatureEnabled(feature);
return enabled ? children : fallback;
}
+15
View File
@@ -0,0 +1,15 @@
export { FeatureGate } from "./FeatureGate";
export { allFeatures, desktopFeatures, getFeature } from "./manifest";
export { getOverrides, setOverride, clearOverride } from "./store";
export type {
FeatureDefinition,
FeaturesManifest,
FeaturePlatform,
FeatureTier,
} from "./types";
export {
useFeatureEnabled,
useFeatureToggle,
useDevToggle,
resolveEnabled,
} from "./useFeatureEnabled";
+17
View File
@@ -0,0 +1,17 @@
import manifestJson from "../../../../features.json";
import type { FeatureDefinition, FeaturesManifest } from "./types";
const manifest = manifestJson as FeaturesManifest;
/** All features defined in the manifest */
export const allFeatures: FeatureDefinition[] = manifest.features;
/** Only features available on desktop */
export const desktopFeatures: FeatureDefinition[] = manifest.features.filter(
(f) => !f.platforms || f.platforms.includes("desktop"),
);
/** Look up a feature by id */
export function getFeature(id: string): FeatureDefinition | undefined {
return manifest.features.find((f) => f.id === id);
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Persistence layer for feature flag overrides.
*
* localStorage keys:
* sprout-feature-overrides — JSON object of { [featureId]: boolean }
* sprout-dev-features — "true" | "false" (global dev toggle)
*/
const OVERRIDES_KEY = "sprout-feature-overrides";
const DEV_TOGGLE_KEY = "sprout-dev-features";
export type FeatureOverrides = Record<string, boolean>;
/** Read all user overrides from localStorage */
export function getOverrides(): FeatureOverrides {
try {
const raw = window.localStorage.getItem(OVERRIDES_KEY);
return raw ? (JSON.parse(raw) as FeatureOverrides) : {};
} catch {
return {};
}
}
/** Persist a single feature override */
export function setOverride(featureId: string, enabled: boolean): void {
const overrides = getOverrides();
overrides[featureId] = enabled;
window.localStorage.setItem(OVERRIDES_KEY, JSON.stringify(overrides));
}
/** Remove a single feature override (revert to default) */
export function clearOverride(featureId: string): void {
const overrides = getOverrides();
delete overrides[featureId];
window.localStorage.setItem(OVERRIDES_KEY, JSON.stringify(overrides));
}
/** Whether the global "Show developer features" toggle is on */
export function getDevToggle(): boolean {
try {
const raw = window.localStorage.getItem(DEV_TOGGLE_KEY);
// Default to true in dev builds, false in prod
if (raw === null) return import.meta.env.DEV;
return raw === "true";
} catch {
return import.meta.env.DEV;
}
}
/** Set the global dev toggle */
export function setDevToggle(enabled: boolean): void {
window.localStorage.setItem(DEV_TOGGLE_KEY, enabled ? "true" : "false");
}
+21
View File
@@ -0,0 +1,21 @@
/** Feature visibility tiers */
export type FeatureTier = "stable" | "experimental" | "dev";
/** Platforms a feature is available on */
export type FeaturePlatform = "desktop" | "mobile";
/** A single feature definition from the manifest */
export interface FeatureDefinition {
id: string;
name: string;
description: string;
tier: FeatureTier;
/** If omitted, feature is available on all platforms */
platforms?: FeaturePlatform[];
}
/** The root manifest schema */
export interface FeaturesManifest {
version: number;
features: FeatureDefinition[];
}
@@ -0,0 +1,108 @@
import { useSyncExternalStore, useCallback } from "react";
import { getFeature } from "./manifest";
import { getOverrides, getDevToggle, setOverride, setDevToggle } from "./store";
import type { FeatureTier } from "./types";
// ---------------------------------------------------------------------------
// Reactive store — components re-render when overrides change
// ---------------------------------------------------------------------------
type Listener = () => void;
const listeners = new Set<Listener>();
function subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function emitChange(): void {
for (const listener of listeners) listener();
}
// Snapshot: a combined key of overrides + dev toggle for change detection
function getSnapshot(): string {
return JSON.stringify({ o: getOverrides(), d: getDevToggle() });
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Returns whether a feature is enabled given its tier and user overrides.
*
* - stable: always true
* - experimental: true only if user opted in
* - dev: true only if in dev build AND global dev toggle is on
*/
export function useFeatureEnabled(featureId: string): boolean {
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const parsed = JSON.parse(snapshot) as {
o: Record<string, boolean>;
d: boolean;
};
const feature = getFeature(featureId);
if (!feature) return false;
return resolveEnabled(feature.tier, featureId, parsed.o, parsed.d);
}
/**
* Hook to toggle a feature override. Returns [enabled, toggle].
*/
export function useFeatureToggle(
featureId: string,
): [boolean, (enabled: boolean) => void] {
const enabled = useFeatureEnabled(featureId);
const toggle = useCallback(
(value: boolean) => {
setOverride(featureId, value);
emitChange();
},
[featureId],
);
return [enabled, toggle];
}
/**
* Hook for the global dev toggle. Returns [enabled, toggle].
*/
export function useDevToggle(): [boolean, (enabled: boolean) => void] {
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const parsed = JSON.parse(snapshot) as { d: boolean };
const toggle = useCallback((value: boolean) => {
setDevToggle(value);
emitChange();
}, []);
return [parsed.d, toggle];
}
// ---------------------------------------------------------------------------
// Pure resolution logic (exported for testing)
// ---------------------------------------------------------------------------
export function resolveEnabled(
tier: FeatureTier,
featureId: string,
overrides: Record<string, boolean>,
devToggle: boolean,
): boolean {
switch (tier) {
case "stable":
return true;
case "experimental":
return overrides[featureId] === true;
case "dev":
if (!import.meta.env.DEV) return false;
if (!devToggle) return false;
// Allow per-feature suppression even in dev
return overrides[featureId] !== false;
default:
return false;
}
}
+75
View File
@@ -0,0 +1,75 @@
{
"version": 1,
"features": [
{
"id": "managed-agents",
"name": "Managed Agents",
"description": "Create, configure, and run AI agents in your workspace",
"tier": "experimental",
"platforms": ["desktop"]
},
{
"id": "channel-templates",
"name": "Channel Templates",
"description": "Pre-configured channel setups with agents and workflows",
"tier": "experimental",
"platforms": ["desktop"]
},
{
"id": "workflows",
"name": "Workflows",
"description": "YAML-defined automations with approval gates",
"tier": "experimental",
"platforms": ["desktop"]
},
{
"id": "projects",
"name": "Projects",
"description": "Git repository browser and collaboration",
"tier": "experimental",
"platforms": ["desktop"]
},
{
"id": "custom-emoji",
"name": "Custom Emoji",
"description": "Workspace emoji palette for reactions and messages",
"tier": "experimental",
"platforms": ["desktop"]
},
{
"id": "huddles",
"name": "Huddles",
"description": "Voice sessions with humans and agents via TTS/STT",
"tier": "dev",
"platforms": ["desktop"]
},
{
"id": "mesh-compute",
"name": "Mesh Compute",
"description": "Peer-to-peer inference sharing across workspace members",
"tier": "dev",
"platforms": ["desktop"]
},
{
"id": "identity-archive",
"name": "Identity Archive",
"description": "Archive and unarchive relay member identities",
"tier": "dev",
"platforms": ["desktop"]
},
{
"id": "relay-members",
"name": "Relay Members",
"description": "Relay membership administration panel",
"tier": "dev",
"platforms": ["desktop"]
},
{
"id": "doctor",
"name": "Doctor",
"description": "Diagnostic and debug panel for troubleshooting",
"tier": "dev",
"platforms": ["desktop"]
}
]
}