diff --git a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx index f23f75a087..b586645012 100644 --- a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx +++ b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx @@ -1,4 +1,4 @@ -import { desktopFeatures, useFeatureToggle, useDevToggle } from "@/shared/features"; +import { desktopFeatures, useFeatureToggle } from "@/shared/features"; import type { FeatureDefinition } from "@/shared/features"; import { Switch } from "@/shared/ui/switch"; @@ -21,19 +21,18 @@ function FeatureRow({ feature }: { feature: FeatureDefinition }) { } export function ExperimentalFeaturesCard() { - const [devEnabled, setDevEnabled] = useDevToggle(); - const isDev = import.meta.env.DEV; - - const experimentalFeatures = desktopFeatures.filter( - (f) => f.tier === "experimental", + const previewFeatures = desktopFeatures.filter( + (f) => f.tier === "preview", + ); + const unstableFeatures = desktopFeatures.filter( + (f) => f.tier === "unstable", ); - const devFeatures = desktopFeatures.filter((f) => f.tier === "dev"); return (

- Experimental Features + Preview

These features are functional but still being refined. Enable them to @@ -42,44 +41,28 @@ export function ExperimentalFeaturesCard() {

- {experimentalFeatures.map((f) => ( + {previewFeatures.map((f) => ( ))}
- {isDev && ( + {unstableFeatures.length > 0 && ( <>

- Developer Features + Unstable

- Only visible in development builds. Toggle the master switch to - hide all dev features. + Here be dragons — these features might break or disappear. Enable + at your own risk.

- - - {devEnabled && ( -
- {devFeatures.map((f) => ( - - ))} -
- )} +
+ {unstableFeatures.map((f) => ( + + ))} +
)}
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index d8fc81cc1d..bf7758b238 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -100,7 +100,7 @@ export const settingsSections: SettingsSectionDescriptor[] = [ }, { value: "experimental", - label: "Experimental", + label: "Preview & Unstable", icon: FlaskConical, }, { diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index a2df6ebf5d..69b413a569 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -127,7 +127,7 @@ export function SettingsView({ // Feature gate check if (s.featureGate) { const feature = getFeature(s.featureGate); - if (feature && !resolveEnabled(feature.tier, feature.id, featureState.o, featureState.d)) { + if (feature && !resolveEnabled(feature.tier, feature.id, featureState)) { return false; } } diff --git a/desktop/src/shared/features/index.ts b/desktop/src/shared/features/index.ts index b155cf2604..4276e345bb 100644 --- a/desktop/src/shared/features/index.ts +++ b/desktop/src/shared/features/index.ts @@ -10,7 +10,6 @@ export type { export { useFeatureEnabled, useFeatureToggle, - useDevToggle, useFeatureSnapshot, resolveEnabled, } from "./useFeatureEnabled"; diff --git a/desktop/src/shared/features/resolveEnabled.test.mjs b/desktop/src/shared/features/resolveEnabled.test.mjs index 8185043484..a8e3931653 100644 --- a/desktop/src/shared/features/resolveEnabled.test.mjs +++ b/desktop/src/shared/features/resolveEnabled.test.mjs @@ -5,64 +5,48 @@ import { resolveEnabled } from "./resolveEnabled.ts"; describe("resolveEnabled", () => { describe("stable tier", () => { - it("always returns true regardless of overrides or env", () => { - assert.equal(resolveEnabled("stable", "channels", {}, false, false), true); - assert.equal(resolveEnabled("stable", "channels", { channels: false }, false, true), true); - assert.equal(resolveEnabled("stable", "channels", {}, true, true), true); + it("always returns true regardless of overrides", () => { + assert.equal(resolveEnabled("stable", "channels", {}), true); + assert.equal(resolveEnabled("stable", "channels", { channels: false }), true); }); }); - describe("experimental tier", () => { + describe("preview tier", () => { it("returns false by default (no override)", () => { - assert.equal(resolveEnabled("experimental", "workflows", {}, true, true), false); + assert.equal(resolveEnabled("preview", "workflows", {}), false); }); it("returns true when user opts in", () => { assert.equal( - resolveEnabled("experimental", "workflows", { workflows: true }, true, true), + resolveEnabled("preview", "workflows", { workflows: true }), true, ); }); it("returns false when user explicitly opts out", () => { assert.equal( - resolveEnabled("experimental", "workflows", { workflows: false }, true, true), + resolveEnabled("preview", "workflows", { workflows: false }), false, ); }); - - it("ignores dev toggle and isDev", () => { - assert.equal( - resolveEnabled("experimental", "workflows", { workflows: true }, false, false), - true, - ); - }); }); - describe("dev tier", () => { - it("returns false in production builds even with devToggle on", () => { - assert.equal(resolveEnabled("dev", "doctor", {}, true, false), false); + describe("unstable tier", () => { + it("returns false by default (no override)", () => { + assert.equal(resolveEnabled("unstable", "mesh-compute", {}), false); }); - it("returns false in dev builds when devToggle is off", () => { - assert.equal(resolveEnabled("dev", "doctor", {}, false, true), false); - }); - - it("returns true in dev builds with devToggle on and no override", () => { - assert.equal(resolveEnabled("dev", "doctor", {}, true, true), true); - }); - - it("returns false when per-feature override is explicitly false", () => { + it("returns true when user opts in", () => { assert.equal( - resolveEnabled("dev", "doctor", { doctor: false }, true, true), - false, + resolveEnabled("unstable", "mesh-compute", { "mesh-compute": true }), + true, ); }); - it("returns true when per-feature override is explicitly true", () => { + it("returns false when user explicitly opts out", () => { assert.equal( - resolveEnabled("dev", "doctor", { doctor: true }, true, true), - true, + resolveEnabled("unstable", "mesh-compute", { "mesh-compute": false }), + false, ); }); }); @@ -70,7 +54,7 @@ describe("resolveEnabled", () => { describe("unknown tier", () => { it("returns false for unrecognized tier values", () => { // @ts-expect-error — testing invalid input - assert.equal(resolveEnabled("unknown", "foo", {}, true, true), false); + assert.equal(resolveEnabled("unknown", "foo", {}), false); }); }); }); diff --git a/desktop/src/shared/features/resolveEnabled.ts b/desktop/src/shared/features/resolveEnabled.ts index 45d62d3da3..c290aa4263 100644 --- a/desktop/src/shared/features/resolveEnabled.ts +++ b/desktop/src/shared/features/resolveEnabled.ts @@ -3,27 +3,19 @@ import type { FeatureTier } from "./types"; /** * Pure resolution logic for feature visibility. * No side effects, no imports beyond types — safe to test in isolation. - * - * @param isDev - Whether the current build is a dev build. - * Defaults to `import.meta.env?.DEV ?? false` for runtime use. */ export function resolveEnabled( tier: FeatureTier, featureId: string, overrides: Record, - devToggle: boolean, - isDev: boolean = (import.meta.env?.DEV as boolean) ?? false, ): boolean { switch (tier) { case "stable": return true; - case "experimental": + case "preview": + return overrides[featureId] === true; + case "unstable": return overrides[featureId] === true; - case "dev": - if (!isDev) return false; - if (!devToggle) return false; - // Allow per-feature suppression even in dev - return overrides[featureId] !== false; default: return false; } diff --git a/desktop/src/shared/features/store.ts b/desktop/src/shared/features/store.ts index eda2a04d9d..0ce183dd75 100644 --- a/desktop/src/shared/features/store.ts +++ b/desktop/src/shared/features/store.ts @@ -3,11 +3,9 @@ * * localStorage keys (versioned to match manifest): * sprout-feature-overrides-v1 — JSON object of { [featureId]: boolean } - * sprout-dev-features-v1 — "true" | "false" (global dev toggle) */ const OVERRIDES_KEY = "sprout-feature-overrides-v1"; -const DEV_TOGGLE_KEY = "sprout-dev-features-v1"; export type FeatureOverrides = Record; @@ -34,20 +32,3 @@ export function clearOverride(featureId: string): void { 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 index 7f06ddd1cc..9b36bddb61 100644 --- a/desktop/src/shared/features/types.ts +++ b/desktop/src/shared/features/types.ts @@ -1,5 +1,5 @@ /** Feature visibility tiers */ -export type FeatureTier = "stable" | "experimental" | "dev"; +export type FeatureTier = "stable" | "preview" | "unstable"; /** Platforms a feature is available on */ export type FeaturePlatform = "desktop" | "mobile"; diff --git a/desktop/src/shared/features/useFeatureEnabled.ts b/desktop/src/shared/features/useFeatureEnabled.ts index a6fe401eb3..f2c3b8af3d 100644 --- a/desktop/src/shared/features/useFeatureEnabled.ts +++ b/desktop/src/shared/features/useFeatureEnabled.ts @@ -1,7 +1,7 @@ import { useSyncExternalStore, useCallback } from "react"; import { getFeature } from "./manifest"; import { resolveEnabled } from "./resolveEnabled"; -import { getOverrides, getDevToggle, setOverride, setDevToggle } from "./store"; +import { getOverrides, setOverride } from "./store"; // --------------------------------------------------------------------------- // Reactive store — components re-render when overrides change @@ -27,24 +27,19 @@ export function emitChange(): void { // Cached snapshot — avoids JSON.parse on every render per hook instance // --------------------------------------------------------------------------- -interface ParsedSnapshot { - o: Record; - d: boolean; -} - let cachedRaw: string | null = null; -let cachedParsed: ParsedSnapshot | null = null; +let cachedParsed: Record | null = null; function getSnapshot(): string { - const raw = JSON.stringify({ o: getOverrides(), d: getDevToggle() }); + const raw = JSON.stringify(getOverrides()); if (raw !== cachedRaw) { cachedRaw = raw; - cachedParsed = JSON.parse(raw) as ParsedSnapshot; + cachedParsed = JSON.parse(raw) as Record; } return raw; } -function getParsedSnapshot(): ParsedSnapshot { +function getParsedSnapshot(): Record { // Ensure snapshot is fresh getSnapshot(); return cachedParsed!; @@ -55,11 +50,11 @@ function getParsedSnapshot(): ParsedSnapshot { // --------------------------------------------------------------------------- /** - * Returns the current parsed feature state (overrides + dev toggle). + * Returns the current parsed feature overrides. * Reactive — re-renders when any feature toggle changes. * Use this in components that need the full state (e.g. SettingsView filtering). */ -export function useFeatureSnapshot(): ParsedSnapshot { +export function useFeatureSnapshot(): Record { useSyncExternalStore(subscribe, getSnapshot, getSnapshot); return getParsedSnapshot(); } @@ -68,11 +63,11 @@ export function useFeatureSnapshot(): ParsedSnapshot { * 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 + * - preview: true only if user opted in + * - unstable: true only if user opted in */ export function useFeatureEnabled(featureId: string): boolean { - const snapshot = useFeatureSnapshot(); + const overrides = useFeatureSnapshot(); const feature = getFeature(featureId); if (!feature) { @@ -84,7 +79,7 @@ export function useFeatureEnabled(featureId: string): boolean { return false; } - return resolveEnabled(feature.tier, featureId, snapshot.o, snapshot.d); + return resolveEnabled(feature.tier, featureId, overrides); } /** @@ -106,19 +101,5 @@ export function useFeatureToggle( return [enabled, toggle]; } -/** - * Hook for the global dev toggle. Returns [enabled, toggle]. - */ -export function useDevToggle(): [boolean, (enabled: boolean) => void] { - const snapshot = useFeatureSnapshot(); - - const toggle = useCallback((value: boolean) => { - setDevToggle(value); - emitChange(); - }, []); - - return [snapshot.d, toggle]; -} - // Re-export for consumers that imported from here export { resolveEnabled } from "./resolveEnabled"; diff --git a/desktop/tests/e2e/screenshots/feature-flags-experimental.png b/desktop/tests/e2e/screenshots/feature-flags-experimental.png new file mode 100644 index 0000000000..339ae69b3f Binary files /dev/null and b/desktop/tests/e2e/screenshots/feature-flags-experimental.png differ diff --git a/desktop/tests/e2e/screenshots/feature-flags-full.png b/desktop/tests/e2e/screenshots/feature-flags-full.png new file mode 100644 index 0000000000..594cc920d9 Binary files /dev/null and b/desktop/tests/e2e/screenshots/feature-flags-full.png differ diff --git a/desktop/tests/e2e/screenshots/feature-flags-toggles-enabled.png b/desktop/tests/e2e/screenshots/feature-flags-toggles-enabled.png new file mode 100644 index 0000000000..c3139467d9 Binary files /dev/null and b/desktop/tests/e2e/screenshots/feature-flags-toggles-enabled.png differ diff --git a/desktop/tests/e2e/screenshots/feature-flags-tour/01-settings-experimental-default.png b/desktop/tests/e2e/screenshots/feature-flags-tour/01-settings-experimental-default.png new file mode 100644 index 0000000000..f484f1d163 Binary files /dev/null and b/desktop/tests/e2e/screenshots/feature-flags-tour/01-settings-experimental-default.png differ diff --git a/desktop/tests/e2e/screenshots/feature-flags-tour/02-settings-experimental-all-enabled.png b/desktop/tests/e2e/screenshots/feature-flags-tour/02-settings-experimental-all-enabled.png new file mode 100644 index 0000000000..004df48883 Binary files /dev/null and b/desktop/tests/e2e/screenshots/feature-flags-tour/02-settings-experimental-all-enabled.png differ diff --git a/desktop/tests/e2e/screenshots/feature-flags-tour/03-settings-agents.png b/desktop/tests/e2e/screenshots/feature-flags-tour/03-settings-agents.png new file mode 100644 index 0000000000..6a78f80c44 Binary files /dev/null and b/desktop/tests/e2e/screenshots/feature-flags-tour/03-settings-agents.png differ diff --git a/desktop/tests/e2e/screenshots/feature-flags-tour/04-settings-templates.png b/desktop/tests/e2e/screenshots/feature-flags-tour/04-settings-templates.png new file mode 100644 index 0000000000..2a15803207 Binary files /dev/null and b/desktop/tests/e2e/screenshots/feature-flags-tour/04-settings-templates.png differ diff --git a/desktop/tests/e2e/screenshots/feature-flags-tour/05-settings-custom-emoji.png b/desktop/tests/e2e/screenshots/feature-flags-tour/05-settings-custom-emoji.png new file mode 100644 index 0000000000..54dc58b815 Binary files /dev/null and b/desktop/tests/e2e/screenshots/feature-flags-tour/05-settings-custom-emoji.png differ diff --git a/desktop/tests/e2e/screenshots/feature-flags-tour/06-settings-custom-emoji.png b/desktop/tests/e2e/screenshots/feature-flags-tour/06-settings-custom-emoji.png new file mode 100644 index 0000000000..54dc58b815 Binary files /dev/null and b/desktop/tests/e2e/screenshots/feature-flags-tour/06-settings-custom-emoji.png differ diff --git a/desktop/tests/e2e/screenshots/feature-flags-tour/06-sidebar-projects.png b/desktop/tests/e2e/screenshots/feature-flags-tour/06-sidebar-projects.png new file mode 100644 index 0000000000..d6f85b765a Binary files /dev/null and b/desktop/tests/e2e/screenshots/feature-flags-tour/06-sidebar-projects.png differ diff --git a/desktop/tests/e2e/screenshots/feature-flags-tour/07-sidebar-agents.png b/desktop/tests/e2e/screenshots/feature-flags-tour/07-sidebar-agents.png new file mode 100644 index 0000000000..951e6365db Binary files /dev/null and b/desktop/tests/e2e/screenshots/feature-flags-tour/07-sidebar-agents.png differ diff --git a/desktop/tests/e2e/screenshots/feature-flags-tour/08-sidebar-workflows.png b/desktop/tests/e2e/screenshots/feature-flags-tour/08-sidebar-workflows.png new file mode 100644 index 0000000000..73e6dd4e28 Binary files /dev/null and b/desktop/tests/e2e/screenshots/feature-flags-tour/08-sidebar-workflows.png differ diff --git a/features.json b/features.json index 295f7ba34c..85823f407e 100644 --- a/features.json +++ b/features.json @@ -15,20 +15,6 @@ "tier": "stable", "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", @@ -36,54 +22,68 @@ "tier": "stable", "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": "pulse", - "name": "Pulse", - "description": "Activity feed with notes, social posts, and agent activity", - "tier": "experimental", - "platforms": ["desktop"] - }, - { - "id": "forum", - "name": "Forum Channels", - "description": "Forum-style threaded channels for long-form discussions", - "tier": "experimental", - "platforms": ["desktop"] - }, { "id": "doctor", "name": "Doctor", "description": "Diagnostic and debug panel for troubleshooting", "tier": "stable", "platforms": ["desktop"] + }, + { + "id": "workflows", + "name": "Workflows", + "description": "YAML-defined automations with approval gates", + "tier": "preview", + "platforms": ["desktop"] + }, + { + "id": "projects", + "name": "Projects", + "description": "Git repository browser and collaboration", + "tier": "preview", + "platforms": ["desktop"] + }, + { + "id": "pulse", + "name": "Pulse", + "description": "Activity feed with notes, social posts, and agent activity", + "tier": "preview", + "platforms": ["desktop"] + }, + { + "id": "forum", + "name": "Forum Channels", + "description": "Forum-style threaded channels for long-form discussions", + "tier": "preview", + "platforms": ["desktop"] + }, + { + "id": "huddles", + "name": "Huddles", + "description": "Voice sessions with humans and agents via TTS/STT", + "tier": "preview", + "platforms": ["desktop"] + }, + { + "id": "mesh-compute", + "name": "Mesh Compute", + "description": "Peer-to-peer inference sharing across workspace members", + "tier": "unstable", + "platforms": ["desktop"] + }, + { + "id": "identity-archive", + "name": "Identity Archive", + "description": "Archive and unarchive relay member identities", + "tier": "unstable", + "platforms": ["desktop"] + }, + { + "id": "relay-members", + "name": "Relay Members", + "description": "Relay membership administration panel", + "tier": "unstable", + "platforms": ["desktop"] } ] }