mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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 (
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
Vendored
+4
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
@@ -6,7 +6,8 @@
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@features-manifest": ["../preview-features.json"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Generated
+4
-5
@@ -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==}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user