feat: preview features (experiments settings UI) (#888)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-06-09 09:53:09 -07:00
committed by GitHub
parent a357e220d8
commit ae430d4dd9
27 changed files with 674 additions and 70 deletions
+2 -1
View File
@@ -70,7 +70,8 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tiptap-markdown": "^0.9.0",
"yaml": "^2.8.3"
"yaml": "^2.8.3",
"zod": "^4.4.3"
},
"devDependencies": {
"@noble/hashes": "^2.0.1",
@@ -1,6 +1,7 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ForumPostRouteSearch = {
@@ -29,6 +30,7 @@ const ChannelRouteScreen = React.lazy(async () => {
});
function ForumPostRouteComponent() {
usePreviewFeatureWarning("forum");
const { channelId, postId } = Route.useParams();
const search = Route.useSearch();
@@ -1,6 +1,7 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
const ProjectDetailScreen = React.lazy(async () => {
@@ -13,6 +14,7 @@ export const Route = createFileRoute("/projects/$projectId")({
});
function ProjectDetailRouteComponent() {
usePreviewFeatureWarning("projects");
const { projectId } = Route.useParams();
return (
+2
View File
@@ -1,6 +1,7 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
const ProjectsScreen = React.lazy(async () => {
@@ -13,6 +14,7 @@ export const Route = createFileRoute("/projects")({
});
function ProjectsRouteComponent() {
usePreviewFeatureWarning("projects");
return (
<React.Suspense
fallback={<ViewLoadingFallback includeHeader kind="projects" />}
+2
View File
@@ -1,6 +1,7 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
const PulseScreen = React.lazy(async () => {
@@ -13,6 +14,7 @@ export const Route = createFileRoute("/pulse")({
});
function PulseRouteComponent() {
usePreviewFeatureWarning("pulse");
return (
<React.Suspense
fallback={<ViewLoadingFallback includeHeader kind="pulse" />}
@@ -1,6 +1,7 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
export const Route = createFileRoute("/workflows/$workflowId")({
@@ -13,6 +14,7 @@ const WorkflowsRouteScreen = React.lazy(async () => {
});
function WorkflowDetailRouteComponent() {
usePreviewFeatureWarning("workflows");
const { workflowId } = Route.useParams();
return (
+2
View File
@@ -1,6 +1,7 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
export const Route = createFileRoute("/workflows")({
@@ -13,6 +14,7 @@ const WorkflowsRouteScreen = React.lazy(async () => {
});
function WorkflowsRouteComponent() {
usePreviewFeatureWarning("workflows");
return (
<React.Suspense
fallback={<ViewLoadingFallback includeHeader kind="workflows" />}
+4
View File
@@ -0,0 +1,4 @@
declare module "@features-manifest" {
const manifest: import("@/shared/features/types").FeaturesManifest;
export default manifest;
}
@@ -0,0 +1,49 @@
import { desktopFeatures, useFeatureToggle } from "@/shared/features";
import type { FeatureDefinition } from "@/shared/features";
import { Switch } from "@/shared/ui/switch";
function FeatureRow({ feature }: { feature: FeatureDefinition }) {
const [enabled, toggle] = useFeatureToggle(feature.id);
const switchId = `feature-toggle-${feature.id}`;
return (
<div 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" id={`${switchId}-label`}>
{feature.name}
</p>
<p className="text-xs text-muted-foreground">{feature.description}</p>
</div>
<Switch
aria-labelledby={`${switchId}-label`}
checked={enabled}
data-testid={switchId}
onCheckedChange={toggle}
/>
</div>
);
}
export function ExperimentalFeaturesCard() {
// Manifest is preview-only by definition; every desktop entry is a preview
// feature.
const previewFeatures = desktopFeatures;
return (
<section className="min-w-0" data-testid="settings-experimental">
<div className="mb-12 min-w-0">
<h2 className="text-2xl font-semibold tracking-tight">Experiments</h2>
<p className="text-base font-normal 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">
{previewFeatures.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,15 +98,22 @@ export const settingsSections: SettingsSectionDescriptor[] = [
label: "Notifications",
icon: BellRing,
},
{
value: "experimental",
label: "Experiments",
icon: FlaskConical,
},
{
value: "agents",
label: "Agents",
icon: Bot,
featureGate: "managed-agents",
},
{
value: "channel-templates",
label: "Templates",
icon: LayoutTemplate,
featureGate: "channel-templates",
},
{
value: "compute",
@@ -122,6 +134,7 @@ export const settingsSections: SettingsSectionDescriptor[] = [
value: "custom-emoji",
label: "Custom Emoji",
icon: Smile,
featureGate: "custom-emoji",
},
{
value: "mobile",
@@ -137,6 +150,7 @@ export const settingsSections: SettingsSectionDescriptor[] = [
value: "doctor",
label: "Doctor",
icon: Stethoscope,
featureGate: "doctor",
},
];
@@ -296,6 +310,8 @@ export function renderSettingsSection(
onSetSoundEnabled={props.onSetSoundEnabled}
/>
);
case "experimental":
return <ExperimentalFeaturesCard />;
case "agents":
return <PreventSleepSettingsCard />;
case "channel-templates":
@@ -3,6 +3,11 @@ 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 {
resolveEnabled,
useFeatureSnapshot,
} from "@/shared/features/useFeatureEnabled";
import { cn } from "@/shared/lib/cn";
import {
Sidebar,
@@ -57,7 +62,14 @@ const settingsNavGroups: Array<{
},
{
label: "App",
sections: ["agents", "compute", "mobile", "updates", "doctor"],
sections: [
"agents",
"compute",
"experimental",
"mobile",
"updates",
"doctor",
],
},
];
@@ -114,9 +126,21 @@ export function SettingsView({
}: SettingsViewProps) {
const { isMobile, open: sidebarOpen, setOpen: setSidebarOpen } = useSidebar();
const myMembershipQuery = useMyRelayMembershipQuery();
const featureState = useFeatureSnapshot();
const visibleSections = React.useMemo(() => {
const membership = myMembershipQuery.data;
return settingsSections.filter((s) => {
// Feature gate check. Manifest is preview-only — if the gate id is in
// the manifest, it's preview and needs an opt-in; if it's not, it's
// stable and renders unconditionally (fail-open).
if (s.featureGate) {
const feature = getFeature(s.featureGate);
if (feature && !resolveEnabled(s.featureGate, featureState)) {
return false;
}
}
// Relay members requires admin/owner role
if (s.value === "relay-members") {
return (
membership != null &&
@@ -125,7 +149,7 @@ export function SettingsView({
}
return true;
});
}, [myMembershipQuery.data]);
}, [myMembershipQuery.data, featureState]);
const [isLoaded, setIsLoaded] = React.useState(false);
const [appVersion, setAppVersion] = React.useState<string | null>(null);
+68 -59
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";
@@ -443,30 +444,34 @@ export function AppSidebar({
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-pulse-view"
isActive={selectedView === "pulse"}
onClick={onSelectPulse}
tooltip="Pulse"
type="button"
>
<Activity className="h-4 w-4" />
<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>
<FeatureGate feature="pulse">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-pulse-view"
isActive={selectedView === "pulse"}
onClick={onSelectPulse}
tooltip="Pulse"
type="button"
>
<Activity className="h-4 w-4" />
<span>Pulse</span>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
<FeatureGate feature="projects">
<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>
</FeatureGate>
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-agents-view"
@@ -487,18 +492,20 @@ export function AppSidebar({
</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>
<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>
@@ -647,29 +654,31 @@ export function AppSidebar({
onUnstarChannel={onUnstarChannel}
/>
</SidebarDndContext>
<ChannelGroupSection
browseAriaLabel="Browse forums"
browseTestId="browse-forums"
createAriaLabel="Create a forum"
hasUnread={unreadChannelIds.size > 0}
isCollapsed={collapsedGroups.forums}
isActiveChannel={selectedView === "channel"}
items={forumChannels}
listTestId="forum-list"
onBrowse={onOpenBrowseForums}
onCreateClick={() => setCreateDialogKind("forum")}
onMarkAllRead={onMarkAllChannelsRead}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("forums")}
selectedChannelId={selectedChannelId}
title="Forums"
unreadChannelIds={unreadChannelIds}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
/>
<FeatureGate feature="forum">
<ChannelGroupSection
browseAriaLabel="Browse forums"
browseTestId="browse-forums"
createAriaLabel="Create a forum"
hasUnread={unreadChannelIds.size > 0}
isCollapsed={collapsedGroups.forums}
isActiveChannel={selectedView === "channel"}
items={forumChannels}
listTestId="forum-list"
onBrowse={onOpenBrowseForums}
onCreateClick={() => setCreateDialogKind("forum")}
onMarkAllRead={onMarkAllChannelsRead}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("forums")}
selectedChannelId={selectedChannelId}
title="Forums"
unreadChannelIds={unreadChannelIds}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
/>
</FeatureGate>
<SidebarSection
action={
<SidebarGroupAction
@@ -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, manifest } from "./manifest";
export { getOverrides, setOverride, clearOverride } from "./store";
export type {
FeatureDefinition,
FeaturesManifest,
FeaturePlatform,
} from "./types";
export {
useFeatureEnabled,
useFeatureToggle,
useFeatureSnapshot,
usePreviewFeatureWarning,
resolveEnabled,
} from "./useFeatureEnabled";
+56
View File
@@ -0,0 +1,56 @@
import manifestJson from "@features-manifest";
import { z } from "zod";
import type { FeatureDefinition, FeaturesManifest } from "./types";
// ---------------------------------------------------------------------------
// Schema — runtime-validates the bundled preview-features.json at startup.
//
// On parse failure we fall back to an empty manifest and log a console warning.
// The app keeps working; gated UI stays hidden; nothing accidentally leaks.
// ---------------------------------------------------------------------------
const FeaturePlatformSchema = z.enum(["desktop", "mobile"]);
const FeatureDefinitionSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string(),
platforms: z.array(FeaturePlatformSchema).optional(),
});
const FeaturesManifestSchema = z.object({
version: z.number().int().nonnegative(),
features: z.array(FeatureDefinitionSchema),
});
const EMPTY_MANIFEST: FeaturesManifest = { version: 1, features: [] };
function loadManifest(): FeaturesManifest {
const result = FeaturesManifestSchema.safeParse(manifestJson);
if (!result.success) {
console.warn(
"[FeatureFlags] preview-features.json failed schema validation; falling back to empty manifest.",
result.error.issues,
);
return EMPTY_MANIFEST;
}
return result.data;
}
const manifest = loadManifest();
/** The validated manifest. Use `manifest.version` for cache/storage keys. */
export { manifest };
/** 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);
}
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { resolveEnabled } from "./resolveEnabled.ts";
describe("resolveEnabled (preview-only)", () => {
it("returns false by default (no override)", () => {
assert.equal(resolveEnabled("workflows", {}), false);
});
it("returns true when user opts in", () => {
assert.equal(resolveEnabled("workflows", { workflows: true }), true);
});
it("returns false when user explicitly opts out", () => {
assert.equal(resolveEnabled("workflows", { workflows: false }), false);
});
it("ignores overrides for unrelated ids", () => {
assert.equal(resolveEnabled("workflows", { pulse: true }), false);
});
});
@@ -0,0 +1,17 @@
/**
* Pure resolution logic for preview-feature visibility.
* No side effects, no imports safe to test in isolation.
*
* The manifest (`preview-features.json`) lists only preview features.
* Anything not in the manifest is stable and resolves true elsewhere
* (see `useFeatureEnabled`). Once you're inside `resolveEnabled`, the
* feature IS in the manifest preview by definition.
*
* Returns true only if the user has explicitly opted in via overrides.
*/
export function resolveEnabled(
featureId: string,
overrides: Record<string, boolean>,
): boolean {
return overrides[featureId] === true;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Persistence layer for feature flag overrides.
*
* The localStorage key is derived from `manifest.version` so a schema bump
* naturally orphans the old key clean reset, no migration logic.
*
* sprout-feature-overrides-v${manifest.version}
* JSON object of { [featureId]: boolean }
*/
import { manifest } from "./manifest";
export const OVERRIDES_KEY = `sprout-feature-overrides-v${manifest.version}`;
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));
}
+23
View File
@@ -0,0 +1,23 @@
/** Platforms a feature is available on */
export type FeaturePlatform = "desktop" | "mobile";
/**
* A single feature definition from the manifest.
*
* The manifest (`preview-features.json`) lists ONLY preview features
* membership signals "this needs gating." Anything not in the manifest is
* treated as stable and renders unconditionally (fail-open).
*/
export interface FeatureDefinition {
id: string;
name: string;
description: string;
/** If omitted, feature is available on all platforms */
platforms?: FeaturePlatform[];
}
/** The root manifest schema */
export interface FeaturesManifest {
version: number;
features: FeatureDefinition[];
}
@@ -0,0 +1,175 @@
import { useSyncExternalStore, useCallback, useEffect } from "react";
import { getFeature } from "./manifest";
import { resolveEnabled } from "./resolveEnabled";
import { getOverrides, setOverride, OVERRIDES_KEY } from "./store";
// ---------------------------------------------------------------------------
// Reactive store — components re-render when overrides change
// ---------------------------------------------------------------------------
type Listener = () => void;
const listeners = new Set<Listener>();
function subscribe(listener: Listener): () => void {
listeners.add(listener);
// Cross-window sync: another window writing the overrides key in
// localStorage fires a "storage" event in this window. Mirror the
// pattern used by useChannelSections / useChannelStars / useChannelMutes /
// useThreadFollows.
const handleStorage = (event: StorageEvent) => {
if (event.key === OVERRIDES_KEY) {
emitChange();
}
};
window.addEventListener("storage", handleStorage);
return () => {
listeners.delete(listener);
window.removeEventListener("storage", handleStorage);
};
}
/** Notify all subscribers that feature state changed */
export function emitChange(): void {
// Invalidate cached snapshot
cachedRaw = null;
cachedParsed = null;
for (const listener of listeners) listener();
}
// ---------------------------------------------------------------------------
// Cached snapshot
//
// useSyncExternalStore requires getSnapshot to return a referentially stable
// value when nothing has changed. Returning `JSON.stringify(getOverrides())`
// fresh on every render would produce a new string each tick → infinite
// re-render. We cache the serialized form and only mint a new parsed object
// when the serialized form changes.
// ---------------------------------------------------------------------------
let cachedRaw: string | null = null;
let cachedParsed: Record<string, boolean> | null = null;
function getSnapshot(): string {
const raw = JSON.stringify(getOverrides());
if (raw !== cachedRaw) {
cachedRaw = raw;
cachedParsed = JSON.parse(raw) as Record<string, boolean>;
}
return raw;
}
/**
* Server-side snapshot for useSyncExternalStore.
*
* Sprout is a Tauri desktop app and does not currently SSR. Returning an
* explicit empty-state snapshot is safer than omitting this argument: under
* any future test harness or SSR experiment, the hook returns "no overrides"
* instead of throwing.
*/
const getServerSnapshot = (): string => "{}";
function getParsedSnapshot(): Record<string, boolean> {
// Ensure snapshot is fresh
getSnapshot();
return cachedParsed!;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* 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(): Record<string, boolean> {
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return getParsedSnapshot();
}
/**
* Returns whether a feature is enabled.
*
* The manifest (`preview-features.json`) lists ONLY preview features:
*
* - in manifest (preview): true only if the user opted in via overrides
* - NOT in manifest (stable): always true (fail-open)
*
* Membership in the manifest signals "this needs gating"; absence means
* "just render it." A stray `<FeatureGate feature="removed-id">` will never
* hide UI.
*/
export function useFeatureEnabled(featureId: string): boolean {
const overrides = useFeatureSnapshot();
const feature = getFeature(featureId);
if (!feature) {
if (import.meta.env.DEV) {
console.warn(
`[FeatureFlags] Unknown feature id: "${featureId}". Check preview-features.json.`,
);
}
return true;
}
return resolveEnabled(featureId, overrides);
}
/**
* 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];
}
/**
* Fires a sonner toast.warning when a preview feature is currently disabled.
*
* Usage: drop in at the top of a route component to give users hitting a
* direct link to a disabled preview feature a hint about how to surface it.
*
* function PulseRouteComponent() {
* usePreviewFeatureWarning("pulse");
* return <PulseScreen />;
* }
*
* Stays a no-op for stable features and for preview features that ARE enabled.
*/
export function usePreviewFeatureWarning(featureId: string): void {
const enabled = useFeatureEnabled(featureId);
const feature = getFeature(featureId);
useEffect(() => {
// No-op for stable features (not in manifest) and preview features
// that ARE enabled. Manifest membership = preview by definition.
if (!feature || enabled) return;
let cancelled = false;
void import("sonner").then(({ toast }) => {
if (cancelled) return;
toast.warning(
`${feature.name} is a preview feature. Enable it in Settings → Experiments to surface it in your sidebar.`,
);
});
return () => {
cancelled = true;
};
}, [feature, enabled]);
}
// Re-export for consumers that imported from here
export { resolveEnabled } from "./resolveEnabled";
+9
View File
@@ -6,7 +6,16 @@ const srcRoot = path.resolve(
"src",
);
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
export function resolve(specifier, context, nextResolve) {
if (specifier === "@features-manifest") {
const resolved = path.join(repoRoot, "preview-features.json");
return nextResolve(resolved, context);
}
if (specifier.startsWith("@/")) {
const resolved = `${srcRoot}/${specifier.slice(2)}.ts`;
return nextResolve(resolved, context);
+32 -2
View File
@@ -1,4 +1,5 @@
import type { Page } from "@playwright/test";
import { FEATURE_OVERRIDES_STORAGE_KEY, PREVIEW_FEATURE_IDS } from "./features";
export const TEST_IDENTITIES = {
tyler: {
@@ -97,6 +98,13 @@ type BridgeOptions = {
relayHttpUrl?: string;
relayWsUrl?: string;
skipOnboardingSeed?: boolean;
/**
* When true (default), seed every preview feature in preview-features.json as
* enabled in localStorage so E2E tests can interact with gated UI without
* clicking through the Experiments settings panel. Set to false in specs
* that exercise the Experiments toggle UI itself.
*/
seedPreviewFeatures?: boolean;
user?: keyof typeof TEST_IDENTITIES;
};
@@ -140,6 +148,17 @@ async function seedDefaultWorkspace(page: Page, relayWsUrl?: string) {
);
}
async function seedPreviewFeaturesEnabled(page: Page) {
await page.addInitScript(
({ key, ids }) => {
const overrides: Record<string, boolean> = {};
for (const id of ids) overrides[id] = true;
window.localStorage.setItem(key, JSON.stringify(overrides));
},
{ key: FEATURE_OVERRIDES_STORAGE_KEY, ids: PREVIEW_FEATURE_IDS },
);
}
export async function installBridge(page: Page, options: BridgeOptions) {
const identity =
options.mode === "relay"
@@ -152,6 +171,11 @@ export async function installBridge(page: Page, options: BridgeOptions) {
if (!options.skipOnboardingSeed) {
await seedOnboardingCompletionForKnownIdentities(page);
}
// Default to opting every preview feature in. Specs that exercise the
// Experiments toggle UI itself pass `seedPreviewFeatures: false`.
if (options.seedPreviewFeatures !== false) {
await seedPreviewFeaturesEnabled(page);
}
await page.addInitScript(
({ identity: bridgeIdentity, mock, mode, relayHttpUrl, relayWsUrl }) => {
@@ -240,18 +264,24 @@ export async function installBridge(page: Page, options: BridgeOptions) {
export async function installMockBridge(
page: Page,
mock?: MockBridgeOptions,
options?: { skipOnboardingSeed?: boolean },
options?: { skipOnboardingSeed?: boolean; seedPreviewFeatures?: boolean },
) {
await installBridge(page, {
mode: "mock",
mock,
skipOnboardingSeed: options?.skipOnboardingSeed,
seedPreviewFeatures: options?.seedPreviewFeatures,
});
}
export async function installRelayBridge(
page: Page,
user: keyof typeof TEST_IDENTITIES = "tyler",
options?: { seedPreviewFeatures?: boolean },
) {
await installBridge(page, { mode: "relay", user });
await installBridge(page, {
mode: "relay",
user,
seedPreviewFeatures: options?.seedPreviewFeatures,
});
}
+37
View File
@@ -0,0 +1,37 @@
// Single source of truth for E2E tests: derive preview-feature data from
// /preview-features.json so we don't have to hand-maintain a parallel array.
//
// Production reads the same JSON via the `@features-manifest` vite alias
// (see `desktop/src/shared/features/manifest.ts`). The localStorage key
// format matches `OVERRIDES_KEY` in `desktop/src/shared/features/store.ts`
// — bumping `version` in `preview-features.json` updates production AND
// every spec automatically.
import featuresManifest from "../../../preview-features.json" with {
type: "json",
};
interface FeatureDefinition {
id: string;
name: string;
description: string;
platforms?: string[];
}
interface FeaturesManifest {
version: number;
features: FeatureDefinition[];
}
const manifest = featuresManifest as FeaturesManifest;
/** IDs of every preview feature on desktop. */
export const PREVIEW_FEATURE_IDS: string[] = manifest.features
.filter((f) => !f.platforms || f.platforms.includes("desktop"))
.map((f) => f.id);
/**
* The localStorage key the production store uses for feature overrides.
* Mirrors `OVERRIDES_KEY` in `src/shared/features/store.ts` so a manifest
* version bump flows through to E2E seeding without manual updates.
*/
export const FEATURE_OVERRIDES_STORAGE_KEY = `sprout-feature-overrides-v${manifest.version}`;
+2 -1
View File
@@ -6,7 +6,8 @@
"module": "ESNext",
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@features-manifest": ["../preview-features.json"]
},
/* Bundler mode */
+2
View File
@@ -1,3 +1,4 @@
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
@@ -24,6 +25,7 @@ export default defineConfig(async () => ({
resolve: {
alias: {
"@": "/src",
"@features-manifest": path.resolve(__dirname, "../preview-features.json"),
},
},
+4 -5
View File
@@ -171,6 +171,9 @@ importers:
yaml:
specifier: ^2.8.3
version: 2.9.0
zod:
specifier: ^4.4.3
version: 4.4.3
devDependencies:
'@noble/hashes':
specifier: ^2.0.1
@@ -1303,7 +1306,6 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.2':
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
@@ -1444,14 +1446,12 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
@@ -1608,7 +1608,6 @@ packages:
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
@@ -3027,7 +3026,7 @@ packages:
engines: {node: '>= 0.4'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+37
View File
@@ -0,0 +1,37 @@
{
"version": 1,
"features": [
{
"id": "workflows",
"name": "Workflows",
"description": "YAML-defined automations with approval gates",
"platforms": [
"desktop"
]
},
{
"id": "projects",
"name": "Projects",
"description": "Git repository browser and collaboration",
"platforms": [
"desktop"
]
},
{
"id": "pulse",
"name": "Pulse",
"description": "Activity feed with notes, social posts, and agent activity",
"platforms": [
"desktop"
]
},
{
"id": "forum",
"name": "Forum Channels",
"description": "Forum-style threaded channels for long-form discussions",
"platforms": [
"desktop"
]
}
]
}