- 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"]
}
]
}