mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add feature flags system with tiered gating
- Add features.json manifest at repo root (single source of truth) - Implement useFeatureEnabled hook + FeatureGate component - Add ExperimentalFeaturesCard settings panel with opt-in toggles - Gate sidebar items: managed-agents, projects, workflows - Gate settings sections via featureGate descriptor field - Three tiers: stable (always on), experimental (opt-in), dev (dev-only) - Global 'Show developer features' toggle for dev tier suppression - localStorage persistence for user overrides - Designed for Flutter consumption (JSON manifest, platforms field) Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { desktopFeatures, useFeatureToggle, useDevToggle } from "@/shared/features";
|
||||
import type { FeatureDefinition } from "@/shared/features";
|
||||
|
||||
function FeatureRow({ feature }: { feature: FeatureDefinition }) {
|
||||
const [enabled, toggle] = useFeatureToggle(feature.id);
|
||||
|
||||
return (
|
||||
<label 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">{feature.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{feature.description}</p>
|
||||
</div>
|
||||
<input
|
||||
checked={enabled}
|
||||
className="h-4 w-4 accent-primary"
|
||||
onChange={(e) => toggle(e.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExperimentalFeaturesCard() {
|
||||
const [devEnabled, setDevEnabled] = useDevToggle();
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
const experimentalFeatures = desktopFeatures.filter(
|
||||
(f) => f.tier === "experimental",
|
||||
);
|
||||
const devFeatures = desktopFeatures.filter((f) => f.tier === "dev");
|
||||
|
||||
return (
|
||||
<section className="min-w-0" data-testid="settings-experimental">
|
||||
<div className="mb-3 min-w-0">
|
||||
<h2 className="text-sm font-semibold tracking-tight">
|
||||
Experimental Features
|
||||
</h2>
|
||||
<p className="text-sm 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">
|
||||
{experimentalFeatures.map((f) => (
|
||||
<FeatureRow feature={f} key={f.id} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isDev && (
|
||||
<>
|
||||
<div className="mb-3 mt-6 min-w-0">
|
||||
<h2 className="text-sm font-semibold tracking-tight">
|
||||
Developer Features
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Only visible in development builds. Toggle the master switch to
|
||||
hide all dev features.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="mb-3 flex items-center justify-between gap-3 rounded-lg border border-primary/30 bg-primary/5 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">Show developer features</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When off, all dev-tier features are hidden
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
checked={devEnabled}
|
||||
className="h-4 w-4 accent-primary"
|
||||
onChange={(e) => setDevEnabled(e.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{devEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{devFeatures.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,20 +98,28 @@ export const settingsSections: SettingsSectionDescriptor[] = [
|
||||
label: "Notifications",
|
||||
icon: BellRing,
|
||||
},
|
||||
{
|
||||
value: "experimental",
|
||||
label: "Experimental",
|
||||
icon: FlaskConical,
|
||||
},
|
||||
{
|
||||
value: "agents",
|
||||
label: "Agents",
|
||||
icon: Bot,
|
||||
featureGate: "managed-agents",
|
||||
},
|
||||
{
|
||||
value: "channel-templates",
|
||||
label: "Templates",
|
||||
icon: LayoutTemplate,
|
||||
featureGate: "channel-templates",
|
||||
},
|
||||
{
|
||||
value: "compute",
|
||||
label: "Compute",
|
||||
icon: Cpu,
|
||||
featureGate: "mesh-compute",
|
||||
},
|
||||
{
|
||||
value: "shortcuts",
|
||||
@@ -117,11 +130,13 @@ export const settingsSections: SettingsSectionDescriptor[] = [
|
||||
value: "relay-members",
|
||||
label: "Relay Access",
|
||||
icon: LockKeyhole,
|
||||
featureGate: "relay-members",
|
||||
},
|
||||
{
|
||||
value: "custom-emoji",
|
||||
label: "Custom Emoji",
|
||||
icon: Smile,
|
||||
featureGate: "custom-emoji",
|
||||
},
|
||||
{
|
||||
value: "mobile",
|
||||
@@ -137,6 +152,7 @@ export const settingsSections: SettingsSectionDescriptor[] = [
|
||||
value: "doctor",
|
||||
label: "Doctor",
|
||||
icon: Stethoscope,
|
||||
featureGate: "doctor",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -296,6 +312,8 @@ export function renderSettingsSection(
|
||||
onSetSoundEnabled={props.onSetSoundEnabled}
|
||||
/>
|
||||
);
|
||||
case "experimental":
|
||||
return <ExperimentalFeaturesCard />;
|
||||
case "agents":
|
||||
return <PreventSleepSettingsCard />;
|
||||
case "channel-templates":
|
||||
|
||||
@@ -3,6 +3,9 @@ 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 { getOverrides, getDevToggle } from "@/shared/features/store";
|
||||
import { resolveEnabled } from "@/shared/features/useFeatureEnabled";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -116,7 +119,18 @@ export function SettingsView({
|
||||
const myMembershipQuery = useMyRelayMembershipQuery();
|
||||
const visibleSections = React.useMemo(() => {
|
||||
const membership = myMembershipQuery.data;
|
||||
const overrides = getOverrides();
|
||||
const devToggle = getDevToggle();
|
||||
|
||||
return settingsSections.filter((s) => {
|
||||
// Feature gate check
|
||||
if (s.featureGate) {
|
||||
const feature = getFeature(s.featureGate);
|
||||
if (feature && !resolveEnabled(feature.tier, feature.id, overrides, devToggle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Relay members requires admin/owner role
|
||||
if (s.value === "relay-members") {
|
||||
return (
|
||||
membership != 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";
|
||||
@@ -455,50 +456,56 @@ export function AppSidebar({
|
||||
<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>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-testid="open-agents-view"
|
||||
isActive={selectedView === "agents"}
|
||||
onClick={onSelectAgents}
|
||||
tooltip="Agents"
|
||||
type="button"
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
<span>Agents</span>
|
||||
</SidebarMenuButton>
|
||||
{shouldShowAgentCount ? (
|
||||
<SidebarMenuBadge
|
||||
className="right-2 rounded-full bg-sidebar-accent/70 px-1.5 text-[11px] text-sidebar-foreground/75 peer-data-[active=true]/menu-button:bg-sidebar-active-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-active-foreground"
|
||||
data-testid="sidebar-agents-count"
|
||||
<FeatureGate feature="projects">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-testid="open-projects-view"
|
||||
isActive={selectedView === "projects"}
|
||||
onClick={onSelectProjects}
|
||||
tooltip="Projects"
|
||||
type="button"
|
||||
>
|
||||
{totalAgentCount}
|
||||
</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>
|
||||
<FolderGit2 className="h-4 w-4" />
|
||||
<span>Projects</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</FeatureGate>
|
||||
<FeatureGate feature="managed-agents">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-testid="open-agents-view"
|
||||
isActive={selectedView === "agents"}
|
||||
onClick={onSelectAgents}
|
||||
tooltip="Agents"
|
||||
type="button"
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
<span>Agents</span>
|
||||
</SidebarMenuButton>
|
||||
{shouldShowAgentCount ? (
|
||||
<SidebarMenuBadge
|
||||
className="right-2 rounded-full bg-sidebar-accent/70 px-1.5 text-[11px] text-sidebar-foreground/75 peer-data-[active=true]/menu-button:bg-sidebar-active-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-active-foreground"
|
||||
data-testid="sidebar-agents-count"
|
||||
>
|
||||
{totalAgentCount}
|
||||
</SidebarMenuBadge>
|
||||
) : null}
|
||||
</SidebarMenuItem>
|
||||
</FeatureGate>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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 } from "./manifest";
|
||||
export { getOverrides, setOverride, clearOverride } from "./store";
|
||||
export type {
|
||||
FeatureDefinition,
|
||||
FeaturesManifest,
|
||||
FeaturePlatform,
|
||||
FeatureTier,
|
||||
} from "./types";
|
||||
export {
|
||||
useFeatureEnabled,
|
||||
useFeatureToggle,
|
||||
useDevToggle,
|
||||
resolveEnabled,
|
||||
} from "./useFeatureEnabled";
|
||||
@@ -0,0 +1,17 @@
|
||||
import manifestJson from "../../../../features.json";
|
||||
import type { FeatureDefinition, FeaturesManifest } from "./types";
|
||||
|
||||
const manifest = manifestJson as FeaturesManifest;
|
||||
|
||||
/** 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,53 @@
|
||||
/**
|
||||
* Persistence layer for feature flag overrides.
|
||||
*
|
||||
* localStorage keys:
|
||||
* sprout-feature-overrides — JSON object of { [featureId]: boolean }
|
||||
* sprout-dev-features — "true" | "false" (global dev toggle)
|
||||
*/
|
||||
|
||||
const OVERRIDES_KEY = "sprout-feature-overrides";
|
||||
const DEV_TOGGLE_KEY = "sprout-dev-features";
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/** 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");
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Feature visibility tiers */
|
||||
export type FeatureTier = "stable" | "experimental" | "dev";
|
||||
|
||||
/** Platforms a feature is available on */
|
||||
export type FeaturePlatform = "desktop" | "mobile";
|
||||
|
||||
/** A single feature definition from the manifest */
|
||||
export interface FeatureDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tier: FeatureTier;
|
||||
/** If omitted, feature is available on all platforms */
|
||||
platforms?: FeaturePlatform[];
|
||||
}
|
||||
|
||||
/** The root manifest schema */
|
||||
export interface FeaturesManifest {
|
||||
version: number;
|
||||
features: FeatureDefinition[];
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useSyncExternalStore, useCallback } from "react";
|
||||
import { getFeature } from "./manifest";
|
||||
import { getOverrides, getDevToggle, setOverride, setDevToggle } from "./store";
|
||||
import type { FeatureTier } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reactive store — components re-render when overrides change
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function subscribe(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function emitChange(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
// Snapshot: a combined key of overrides + dev toggle for change detection
|
||||
function getSnapshot(): string {
|
||||
return JSON.stringify({ o: getOverrides(), d: getDevToggle() });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export function useFeatureEnabled(featureId: string): boolean {
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const parsed = JSON.parse(snapshot) as {
|
||||
o: Record<string, boolean>;
|
||||
d: boolean;
|
||||
};
|
||||
|
||||
const feature = getFeature(featureId);
|
||||
if (!feature) return false;
|
||||
|
||||
return resolveEnabled(feature.tier, featureId, parsed.o, parsed.d);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the global dev toggle. Returns [enabled, toggle].
|
||||
*/
|
||||
export function useDevToggle(): [boolean, (enabled: boolean) => void] {
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const parsed = JSON.parse(snapshot) as { d: boolean };
|
||||
|
||||
const toggle = useCallback((value: boolean) => {
|
||||
setDevToggle(value);
|
||||
emitChange();
|
||||
}, []);
|
||||
|
||||
return [parsed.d, toggle];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure resolution logic (exported for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function resolveEnabled(
|
||||
tier: FeatureTier,
|
||||
featureId: string,
|
||||
overrides: Record<string, boolean>,
|
||||
devToggle: boolean,
|
||||
): boolean {
|
||||
switch (tier) {
|
||||
case "stable":
|
||||
return true;
|
||||
case "experimental":
|
||||
return overrides[featureId] === true;
|
||||
case "dev":
|
||||
if (!import.meta.env.DEV) return false;
|
||||
if (!devToggle) return false;
|
||||
// Allow per-feature suppression even in dev
|
||||
return overrides[featureId] !== false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"version": 1,
|
||||
"features": [
|
||||
{
|
||||
"id": "managed-agents",
|
||||
"name": "Managed Agents",
|
||||
"description": "Create, configure, and run AI agents in your workspace",
|
||||
"tier": "experimental",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "channel-templates",
|
||||
"name": "Channel Templates",
|
||||
"description": "Pre-configured channel setups with agents and workflows",
|
||||
"tier": "experimental",
|
||||
"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",
|
||||
"description": "Workspace emoji palette for reactions and messages",
|
||||
"tier": "experimental",
|
||||
"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": "doctor",
|
||||
"name": "Doctor",
|
||||
"description": "Diagnostic and debug panel for troubleshooting",
|
||||
"tier": "dev",
|
||||
"platforms": ["desktop"]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user