diff --git a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx new file mode 100644 index 000000000..458edd58f --- /dev/null +++ b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx @@ -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 ( + + + {feature.name} + {feature.description} + + toggle(e.target.checked)} + type="checkbox" + /> + + ); +} + +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 ( + + + + Experimental Features + + + These features are functional but still being refined. Enable them to + try new capabilities early. + + + + + {experimentalFeatures.map((f) => ( + + ))} + + + {isDev && ( + <> + + + Developer Features + + + Only visible in development builds. Toggle the master switch to + hide all dev features. + + + + + + Show developer features + + When off, all dev-tier features are hidden + + + setDevEnabled(e.target.checked)} + type="checkbox" + /> + + + {devEnabled && ( + + {devFeatures.map((f) => ( + + ))} + + )} + > + )} + + ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 4405163ea..d8fc81cc1 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -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 ; case "agents": return ; case "channel-templates": diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 958a47861..231bb33d5 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -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 && diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 771ec8f35..8e2f9bf5d 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -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({ Pulse - - - - Projects - - - - - - Agents - - {shouldShowAgentCount ? ( - + + - {totalAgentCount} - - ) : null} - - - - - Workflows - - + + Projects + + + + + + + + Agents + + {shouldShowAgentCount ? ( + + {totalAgentCount} + + ) : null} + + + + + + + Workflows + + + diff --git a/desktop/src/shared/features/FeatureGate.tsx b/desktop/src/shared/features/FeatureGate.tsx new file mode 100644 index 000000000..b81728ab8 --- /dev/null +++ b/desktop/src/shared/features/FeatureGate.tsx @@ -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: + * + * + * + */ +export function FeatureGate({ + feature, + children, + fallback = null, +}: FeatureGateProps): ReactNode { + const enabled = useFeatureEnabled(feature); + return enabled ? children : fallback; +} diff --git a/desktop/src/shared/features/index.ts b/desktop/src/shared/features/index.ts new file mode 100644 index 000000000..a25d45f9b --- /dev/null +++ b/desktop/src/shared/features/index.ts @@ -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"; diff --git a/desktop/src/shared/features/manifest.ts b/desktop/src/shared/features/manifest.ts new file mode 100644 index 000000000..04f25cad6 --- /dev/null +++ b/desktop/src/shared/features/manifest.ts @@ -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); +} diff --git a/desktop/src/shared/features/store.ts b/desktop/src/shared/features/store.ts new file mode 100644 index 000000000..658c61313 --- /dev/null +++ b/desktop/src/shared/features/store.ts @@ -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; + +/** 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"); +} diff --git a/desktop/src/shared/features/types.ts b/desktop/src/shared/features/types.ts new file mode 100644 index 000000000..7f06ddd1c --- /dev/null +++ b/desktop/src/shared/features/types.ts @@ -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[]; +} diff --git a/desktop/src/shared/features/useFeatureEnabled.ts b/desktop/src/shared/features/useFeatureEnabled.ts new file mode 100644 index 000000000..775348228 --- /dev/null +++ b/desktop/src/shared/features/useFeatureEnabled.ts @@ -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(); + +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; + 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, + 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; + } +} diff --git a/features.json b/features.json new file mode 100644 index 000000000..a5c803571 --- /dev/null +++ b/features.json @@ -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"] + } + ] +}
{feature.name}
{feature.description}
+ These features are functional but still being refined. Enable them to + try new capabilities early. +
+ Only visible in development builds. Toggle the master switch to + hide all dev features. +
Show developer features
+ When off, all dev-tier features are hidden +