diff --git a/.env.example b/.env.example index a6740f7a7d..c0b913d746 100644 --- a/.env.example +++ b/.env.example @@ -206,6 +206,9 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # app launch while keeping the current identity and relay data. # VITE_BUZZ_FORCE_FRESH_ONBOARDING=true +# Makes the default-off Bestie experiment available in eligible builds. +# VITE_BUZZ_BESTIE=1 + # ── Subscription & filtering ───────────────────────────────────────────────── # Subscribe mode: "mentions" (default), "all", or "config" (rule-based). # BUZZ_ACP_SUBSCRIBE=mentions diff --git a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx index 50052c014f..db08e8b702 100644 --- a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx +++ b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx @@ -1,5 +1,5 @@ import { setAgentManagedProfiles } from "@/shared/api/tauri"; -import { desktopFeatures, useFeatureToggle } from "@/shared/features"; +import { availableDesktopFeatures, useFeatureToggle } from "@/shared/features"; import type { FeatureDefinition } from "@/shared/features"; import { Switch } from "@/shared/ui/switch"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; @@ -42,7 +42,7 @@ function FeatureRow({ feature }: { feature: FeatureDefinition }) { export function ExperimentalFeaturesCard() { // Manifest is preview-only by definition; every desktop entry is a preview // feature. - const previewFeatures = desktopFeatures; + const previewFeatures = availableDesktopFeatures; return (
diff --git a/desktop/src/shared/features/buildAvailability.test.mjs b/desktop/src/shared/features/buildAvailability.test.mjs new file mode 100644 index 0000000000..046615876c --- /dev/null +++ b/desktop/src/shared/features/buildAvailability.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + filterBuildAvailableFeatures, + isFeatureBuildAvailable, +} from "./buildAvailability.ts"; + +const publicFeature = { + id: "projects", + name: "Projects", + description: "", +}; +const bestie = { + id: "bestie", + name: "Bestie", + description: "", + requiredBuildFlag: "bestie", +}; + +describe("feature build availability", () => { + it("keeps features without a required build flag available", () => { + assert.equal( + isFeatureBuildAvailable(publicFeature, { bestie: false }), + true, + ); + }); + + it("does not allow a required feature into an ineligible build", () => { + assert.equal(isFeatureBuildAvailable(bestie, { bestie: false }), false); + }); + + it("shows a required feature when its build opts in", () => { + assert.equal(isFeatureBuildAvailable(bestie, { bestie: true }), true); + }); + + it("filters unavailable features out of the experiment picker", () => { + assert.deepEqual( + filterBuildAvailableFeatures([publicFeature, bestie], { bestie: false }), + [publicFeature], + ); + }); +}); diff --git a/desktop/src/shared/features/buildAvailability.ts b/desktop/src/shared/features/buildAvailability.ts new file mode 100644 index 0000000000..3c6b3bcb44 --- /dev/null +++ b/desktop/src/shared/features/buildAvailability.ts @@ -0,0 +1,24 @@ +import type { FeatureBuildFlag, FeatureDefinition } from "./types"; + +export type FeatureBuildFlags = Record; + +/** + * Build capabilities are deliberately separate from user preference. A local + * storage override cannot make an unavailable feature visible. + */ +export function isFeatureBuildAvailable( + feature: FeatureDefinition, + buildFlags: FeatureBuildFlags, +): boolean { + const requiredFlag = feature.requiredBuildFlag; + return requiredFlag ? buildFlags[requiredFlag] : true; +} + +export function filterBuildAvailableFeatures( + features: FeatureDefinition[], + buildFlags: FeatureBuildFlags, +): FeatureDefinition[] { + return features.filter((feature) => + isFeatureBuildAvailable(feature, buildFlags), + ); +} diff --git a/desktop/src/shared/features/index.ts b/desktop/src/shared/features/index.ts index 9f6db1b156..f476cbd14d 100644 --- a/desktop/src/shared/features/index.ts +++ b/desktop/src/shared/features/index.ts @@ -1,8 +1,10 @@ export { FeatureGate } from "./FeatureGate"; export { allFeatures, desktopFeatures, getFeature, manifest } from "./manifest"; +export { availableDesktopFeatures } from "./runtimeBuildAvailability"; export { getOverrides, setOverride, clearOverride } from "./store"; export type { FeatureDefinition, + FeatureBuildFlag, FeaturesManifest, FeaturePlatform, } from "./types"; diff --git a/desktop/src/shared/features/manifest.ts b/desktop/src/shared/features/manifest.ts index 1e6f48ae01..8ebdae5c06 100644 --- a/desktop/src/shared/features/manifest.ts +++ b/desktop/src/shared/features/manifest.ts @@ -8,6 +8,7 @@ import type { FeatureDefinition, FeaturesManifest } from "./types"; // The app keeps working; gated UI stays hidden; nothing accidentally leaks. const FeaturePlatformSchema = z.enum(["desktop", "mobile"]); +const FeatureBuildFlagSchema = z.enum(["bestie"]); const FeatureDefinitionSchema = z.object({ id: z.string().min(1), @@ -15,6 +16,7 @@ const FeatureDefinitionSchema = z.object({ description: z.string(), defaultEnabled: z.boolean().optional(), platforms: z.array(FeaturePlatformSchema).optional(), + requiredBuildFlag: FeatureBuildFlagSchema.optional(), }); const FeaturesManifestSchema = z.object({ diff --git a/desktop/src/shared/features/resolveEnabled.test.mjs b/desktop/src/shared/features/resolveEnabled.test.mjs index d1639f6e4f..dc88aa8db7 100644 --- a/desktop/src/shared/features/resolveEnabled.test.mjs +++ b/desktop/src/shared/features/resolveEnabled.test.mjs @@ -30,4 +30,20 @@ describe("resolveEnabled (preview-only)", () => { it("ignores overrides for unrelated ids", () => { assert.equal(resolveEnabled("workflows", { pulse: true }), false); }); + + it("requires both build availability and a user opt-in", () => { + const cases = [ + { buildAvailable: false, optedIn: false, expected: false }, + { buildAvailable: false, optedIn: true, expected: false }, + { buildAvailable: true, optedIn: false, expected: false }, + { buildAvailable: true, optedIn: true, expected: true }, + ]; + + for (const { buildAvailable, optedIn, expected } of cases) { + assert.equal( + resolveEnabled("bestie", { bestie: optedIn }, false, buildAvailable), + expected, + ); + } + }); }); diff --git a/desktop/src/shared/features/resolveEnabled.ts b/desktop/src/shared/features/resolveEnabled.ts index e3c544ec50..3aee2738dd 100644 --- a/desktop/src/shared/features/resolveEnabled.ts +++ b/desktop/src/shared/features/resolveEnabled.ts @@ -14,6 +14,8 @@ export function resolveEnabled( featureId: string, overrides: Record, defaultEnabled = false, + buildAvailable = true, ): boolean { + if (!buildAvailable) return false; return overrides[featureId] ?? defaultEnabled; } diff --git a/desktop/src/shared/features/runtimeBuildAvailability.ts b/desktop/src/shared/features/runtimeBuildAvailability.ts new file mode 100644 index 0000000000..d0966f7095 --- /dev/null +++ b/desktop/src/shared/features/runtimeBuildAvailability.ts @@ -0,0 +1,21 @@ +import { + filterBuildAvailableFeatures, + isFeatureBuildAvailable, + type FeatureBuildFlags, +} from "./buildAvailability"; +import { desktopFeatures } from "./manifest"; + +const runtimeBuildFlags: FeatureBuildFlags = { + bestie: import.meta.env.VITE_BUZZ_BESTIE === "1", +}; + +export function isFeatureAvailableInThisBuild( + feature: Parameters[0], +): boolean { + return isFeatureBuildAvailable(feature, runtimeBuildFlags); +} + +export const availableDesktopFeatures = filterBuildAvailableFeatures( + desktopFeatures, + runtimeBuildFlags, +); diff --git a/desktop/src/shared/features/types.ts b/desktop/src/shared/features/types.ts index c452cd54b4..4fdf07f2af 100644 --- a/desktop/src/shared/features/types.ts +++ b/desktop/src/shared/features/types.ts @@ -1,6 +1,9 @@ /** Platforms a feature is available on */ export type FeaturePlatform = "desktop" | "mobile"; +/** Build-time capabilities that can make a preview available to users. */ +export type FeatureBuildFlag = "bestie"; + /** * A single feature definition from the manifest. * @@ -16,6 +19,8 @@ export interface FeatureDefinition { defaultEnabled?: boolean; /** If omitted, feature is available on all platforms */ platforms?: FeaturePlatform[]; + /** If present, the build must opt in before the user can see or enable it */ + requiredBuildFlag?: FeatureBuildFlag; } /** The root manifest schema */ diff --git a/desktop/src/shared/features/useFeatureEnabled.ts b/desktop/src/shared/features/useFeatureEnabled.ts index b0c9878d0b..356a362cca 100644 --- a/desktop/src/shared/features/useFeatureEnabled.ts +++ b/desktop/src/shared/features/useFeatureEnabled.ts @@ -1,6 +1,7 @@ import { useSyncExternalStore, useCallback, useEffect } from "react"; import { getFeature } from "./manifest"; import { resolveEnabled } from "./resolveEnabled"; +import { isFeatureAvailableInThisBuild } from "./runtimeBuildAvailability"; import { getOverrides, setOverride, OVERRIDES_KEY } from "./store"; type Listener = () => void; @@ -102,7 +103,12 @@ export function useFeatureEnabled(featureId: string): boolean { return true; } - return resolveEnabled(featureId, overrides, feature.defaultEnabled); + return resolveEnabled( + featureId, + overrides, + feature.defaultEnabled, + isFeatureAvailableInThisBuild(feature), + ); } /** diff --git a/preview-features.json b/preview-features.json index 388f1c39b0..79460c3cad 100644 --- a/preview-features.json +++ b/preview-features.json @@ -30,6 +30,13 @@ "name": "Agent-managed profiles", "description": "Let agents manage their own relay name and avatar instead of restoring the desktop copy", "platforms": ["desktop"] + }, + { + "id": "bestie", + "name": "Bestie", + "description": "Try a personal agent that is always close at hand", + "platforms": ["desktop"], + "requiredBuildFlag": "bestie" } ] }