[demo] PostHog feature flag integration for Run button experiment

- Add FeatureFlaggedRunButton wrapper component
- Add ExperimentalRunButton with JSON-driven styling
- Extend useFeatureFlags to support remoteConfig and variant payloads
- Support 4 experimental button variants (bold-gradient, animated, playful, minimal)
- Add documentation for feature flag payload structure

NOTE: This is a DEMO and should NOT be merged
This commit is contained in:
Luke Mino-Altherr
2025-12-04 16:12:52 -08:00
parent d314172b98
commit 0b0af89321
6 changed files with 809 additions and 27 deletions

View File

@@ -0,0 +1,229 @@
# Feature Flags System Explanation
## Overview
The `useFeatureFlag` hook (actually named `useFeatureFlags`) is a Vue 3 composable that provides **reactive access to server-side feature flags** received via WebSocket from the backend. It enables capability negotiation between frontend and backend, allowing the UI to adapt based on what features the server supports.
## Architecture Flow
```
1. Frontend connects via WebSocket
2. Frontend sends client feature flags (first message)
3. Backend responds with server feature flags
4. Frontend stores flags in api.serverFeatureFlags
5. Components use useFeatureFlags() to access flags reactively
```
## Core Implementation
### 1. The `useFeatureFlags` Composable
**Location:** `src/composables/useFeatureFlags.ts`
The composable returns two things:
#### A. Predefined `flags` Object
A reactive object with getter properties for commonly-used feature flags:
```typescript
const { flags } = useFeatureFlags()
// Access predefined flags
flags.supportsPreviewMetadata // boolean | undefined
flags.maxUploadSize // number | undefined
flags.supportsManagerV4 // boolean | undefined
flags.modelUploadButtonEnabled // boolean (checks remoteConfig first)
flags.assetUpdateOptionsEnabled // boolean (checks remoteConfig first)
```
**Key Points:**
- Uses Vue's `reactive()` to make the object reactive
- Each getter calls `api.getServerFeature()` which reads from `api.serverFeatureFlags`
- Some flags (like `modelUploadButtonEnabled`) check `remoteConfig` first (from `/api/features` endpoint) before falling back to WebSocket flags
- Returns a `readonly()` wrapper to prevent external mutation
#### B. Generic `featureFlag` Function
A function that creates a computed ref for any feature flag path:
```typescript
const { featureFlag } = useFeatureFlags()
// Create a reactive computed ref for any flag
const myFlag = featureFlag('custom.feature.path', false) // defaultValue is optional
// myFlag is a ComputedRef that updates when serverFeatureFlags changes
```
**Key Points:**
- Accepts any string path (supports dot notation for nested values)
- Returns a `computed()` ref that automatically updates when flags change
- Generic type parameter allows type safety: `featureFlag<boolean>('flag', false)`
### 2. The Underlying API Layer
**Location:** `src/scripts/api.ts`
The `ComfyApi` class manages feature flags:
```typescript
class ComfyApi {
// Stores flags received from backend
serverFeatureFlags: Record<string, unknown> = {}
// Retrieves a flag value using dot notation
getServerFeature<T>(featureName: string, defaultValue?: T): T {
return get(this.serverFeatureFlags, featureName, defaultValue) as T
}
}
```
**How Flags Are Received:**
1. WebSocket connection is established
2. Frontend sends client feature flags as first message
3. Backend responds with a `feature_flags` message type
4. The message handler stores it: `this.serverFeatureFlags = msg.data`
**The `get` Function:**
- Uses `es-toolkit/compat`'s `get` function (lodash-style)
- Supports dot notation: `'extension.manager.supports_v4'` accesses nested objects
- Returns `defaultValue` if the path doesn't exist
### 3. Remote Config Integration
**Location:** `src/platform/remoteConfig/remoteConfig.ts`
Some flags check `remoteConfig` first (loaded from `/api/features` endpoint):
```typescript
// Example from modelUploadButtonEnabled
return (
remoteConfig.value.model_upload_button_enabled ?? // Check remote config first
api.getServerFeature(ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED, false) // Fallback
)
```
**Why Two Sources?**
- `remoteConfig`: Fetched via HTTP at app startup, can be updated without WebSocket
- WebSocket flags: Real-time capability negotiation, updated on reconnection
## Usage Patterns
### Pattern 1: Using Predefined Flags
```typescript
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const { flags } = useFeatureFlags()
// In template
if (flags.supportsPreviewMetadata) {
// Use enhanced preview feature
}
// In script
const maxSize = flags.maxUploadSize ?? 100 * 1024 * 1024 // Default 100MB
```
### Pattern 2: Using Generic featureFlag Function
```typescript
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const { featureFlag } = useFeatureFlags()
// Create a reactive computed ref
const customFeature = featureFlag<boolean>('extension.custom.feature', false)
// Use in template (automatically reactive)
// <div v-if="customFeature">New Feature UI</div>
// Use in script
watch(customFeature, (enabled) => {
if (enabled) {
// Feature was enabled
}
})
```
### Pattern 3: Direct API Access (Non-Reactive)
```typescript
import { api } from '@/scripts/api'
// Direct access (not reactive, use sparingly)
if (api.serverSupportsFeature('supports_preview_metadata')) {
// Feature is supported
}
const maxSize = api.getServerFeature('max_upload_size', 100 * 1024 * 1024)
```
## Reactivity Explained
The composable is **reactive** because:
1. **Predefined flags**: Use `reactive()` with getters, so when `api.serverFeatureFlags` changes, Vue's reactivity system detects it
2. **Generic featureFlag**: Returns `computed()`, which automatically tracks `api.getServerFeature()` calls and re-evaluates when flags change
3. **WebSocket updates**: When flags are updated via WebSocket, `api.serverFeatureFlags` is reassigned, triggering reactivity
## Adding New Feature Flags
### Step 1: Add to Enum (if it's a core flag)
```typescript
// In useFeatureFlags.ts
export enum ServerFeatureFlag {
// ... existing flags
MY_NEW_FEATURE = 'my_new_feature'
}
```
### Step 2: Add to flags Object (if commonly used)
```typescript
// In useFeatureFlags.ts flags object
get myNewFeature() {
return api.getServerFeature(ServerFeatureFlag.MY_NEW_FEATURE, false)
}
```
### Step 3: Use in Components
```typescript
const { flags } = useFeatureFlags()
if (flags.myNewFeature) {
// Use the feature
}
```
**OR** use the generic function without modifying the composable:
```typescript
const { featureFlag } = useFeatureFlags()
const myFeature = featureFlag('my_new_feature', false)
```
## Important Notes
1. **Flags are server-driven**: The backend controls which flags are available
2. **Default values**: Always provide sensible defaults when using `getServerFeature()`
3. **Reactivity**: The composable ensures UI updates automatically when flags change (e.g., on WebSocket reconnection)
4. **Type safety**: Use TypeScript generics with `featureFlag<T>()` for type safety
5. **Dot notation**: Feature flags can be nested, use dot notation: `'extension.manager.supports_v4'`
6. **Remote config priority**: Some flags check `remoteConfig` first, then fall back to WebSocket flags
## Testing
See `tests-ui/tests/composables/useFeatureFlags.test.ts` for examples of:
- Mocking `api.getServerFeature()`
- Testing reactive behavior
- Testing default values
- Testing nested paths
## Related Files
- `src/composables/useFeatureFlags.ts` - The main composable
- `src/scripts/api.ts` - API layer with `getServerFeature()` method
- `src/platform/remoteConfig/remoteConfig.ts` - Remote config integration
- `docs/FEATURE_FLAGS.md` - Full system documentation
- `tests-ui/tests/composables/useFeatureFlags.test.ts` - Unit tests

196
FEATURE_FLAG_PAYLOAD.md Normal file
View File

@@ -0,0 +1,196 @@
# Feature Flag Payload Shape
## Feature Flag Key
`demo-run-button-experiment`
## Expected Structure
The feature flag value should be either:
### Control (Original Button)
- `false`
- `null`
- `undefined`
- Not set
When any of these values are present, the original SplitButton will be displayed.
### Experiment (Experimental Button)
An object with the following structure:
```typescript
{
variant: string, // Required: variant name (e.g., "bold-gradient", "animated", "playful", "minimal")
payload?: { // Optional: styling and content overrides
label?: string, // Button text (default: "Run")
icon?: string, // Icon class (default: variant-specific)
backgroundColor?: string, // Background color/class (default: variant-specific)
textColor?: string, // Text color/class (default: variant-specific)
borderRadius?: string, // Border radius class (default: variant-specific)
padding?: string // Padding class (default: "px-4 py-2")
}
}
```
## Example Payloads
### Bold Gradient Variant
```json
{
"variant": "bold-gradient",
"payload": {
"label": "Run",
"icon": "icon-[lucide--zap]",
"backgroundColor": "transparent",
"textColor": "white",
"borderRadius": "rounded-xl"
}
}
```
### Animated Variant
```json
{
"variant": "animated",
"payload": {
"label": "Launch",
"icon": "icon-[lucide--rocket]",
"backgroundColor": "bg-primary-background",
"textColor": "white",
"borderRadius": "rounded-full"
}
}
```
### Playful Variant
```json
{
"variant": "playful",
"payload": {
"label": "Go!",
"icon": "icon-[lucide--sparkles]",
"backgroundColor": "bg-gradient-to-br from-yellow-400 to-orange-500",
"textColor": "white",
"borderRadius": "rounded-2xl"
}
}
```
### Minimal Variant
```json
{
"variant": "minimal",
"payload": {
"label": "Run",
"icon": "icon-[lucide--play]",
"backgroundColor": "bg-white",
"textColor": "text-gray-800",
"borderRadius": "rounded-md"
}
}
```
## Payload Properties
### `variant` (required)
- Type: `string`
- Description: The variant name that determines the base styling and behavior
- Supported values:
- `"bold-gradient"` - Gradient background with animated effect
- `"animated"` - Pulsing animation effect
- `"playful"` - Sparkle effects with playful styling
- `"minimal"` - Clean, minimal design
- Any custom variant name (will use default styling)
### `payload.label` (optional)
- Type: `string`
- Default: `"Run"` (or variant-specific default)
- Description: The text displayed on the button
### `payload.icon` (optional)
- Type: `string`
- Default: Variant-specific icon
- Description: Icon class name (e.g., `"icon-[lucide--play]"`)
- Examples:
- `"icon-[lucide--play]"` - Play icon
- `"icon-[lucide--zap]"` - Lightning bolt
- `"icon-[lucide--rocket]"` - Rocket
- `"icon-[lucide--sparkles]"` - Sparkles
### `payload.backgroundColor` (optional)
- Type: `string`
- Default: Variant-specific background
- Description: Tailwind CSS class or CSS color value for background
- Examples:
- `"bg-primary-background"` - Primary background color
- `"bg-white"` - White background
- `"transparent"` - Transparent (for gradient overlays)
- `"bg-gradient-to-br from-yellow-400 to-orange-500"` - Gradient
### `payload.textColor` (optional)
- Type: `string`
- Default: Variant-specific text color
- Description: Tailwind CSS class or CSS color value for text
- Examples:
- `"white"` - White text (CSS color)
- `"text-white"` - White text (Tailwind class)
- `"text-gray-800"` - Dark gray text
### `payload.borderRadius` (optional)
- Type: `string`
- Default: Variant-specific border radius
- Description: Tailwind CSS border radius class
- Examples:
- `"rounded-md"` - Medium border radius
- `"rounded-xl"` - Extra large border radius
- `"rounded-full"` - Fully rounded (pill shape)
- `"rounded-2xl"` - 2x extra large border radius
### `payload.padding` (optional)
- Type: `string`
- Default: `"px-4 py-2"`
- Description: Tailwind CSS padding classes
- Examples:
- `"px-4 py-2"` - Standard padding
- `"px-6 py-3"` - Larger padding
- `"px-2 py-1"` - Smaller padding
## Backend Integration
The backend should send this feature flag via WebSocket in the `feature_flags` message:
```json
{
"type": "feature_flags",
"data": {
"demo-run-button-experiment": {
"variant": "bold-gradient",
"payload": {
"label": "Run",
"icon": "icon-[lucide--zap]",
"textColor": "white",
"borderRadius": "rounded-xl"
}
}
}
}
```
Or to show the control (original button):
```json
{
"type": "feature_flags",
"data": {
"demo-run-button-experiment": false
}
}
```
## Notes
- All `payload` properties are optional - if omitted, variant-specific defaults will be used
- The `variant` property is required when the flag is truthy
- Color values can be either Tailwind classes (e.g., `"text-white"`) or CSS color values (e.g., `"white"`)
- The component will automatically handle both formats

View File

@@ -1,35 +1,42 @@
<template>
<div class="queue-button-group flex">
<SplitButton
v-tooltip.bottom="{
value: queueButtonTooltip,
showDelay: 600
}"
class="comfyui-queue-button"
:label="String(activeQueueModeMenuItem?.label ?? '')"
severity="primary"
size="small"
:model="queueModeMenuItems"
data-testid="queue-button"
@click="queuePrompt"
<FeatureFlaggedRunButton
flag-key="demo-run-button-experiment"
:on-click="queuePrompt"
>
<template #icon>
<i :class="iconClass" />
</template>
<template #item="{ item }">
<Button
v-tooltip="{
value: item.tooltip,
<template #control>
<SplitButton
v-tooltip.bottom="{
value: queueButtonTooltip,
showDelay: 600
}"
:label="String(item.label ?? '')"
:icon="item.icon"
:severity="item.key === queueMode ? 'primary' : 'secondary'"
class="comfyui-queue-button"
:label="String(activeQueueModeMenuItem?.label ?? '')"
severity="primary"
size="small"
text
/>
:model="queueModeMenuItems"
data-testid="queue-button"
@click="queuePrompt"
>
<template #icon>
<i :class="iconClass" />
</template>
<template #item="{ item }">
<Button
v-tooltip="{
value: item.tooltip,
showDelay: 600
}"
:label="String(item.label ?? '')"
:icon="item.icon"
:severity="item.key === queueMode ? 'primary' : 'secondary'"
size="small"
text
/>
</template>
</SplitButton>
</template>
</SplitButton>
</FeatureFlaggedRunButton>
<BatchCountEdit />
</div>
</template>
@@ -52,6 +59,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import BatchCountEdit from '../BatchCountEdit.vue'
import FeatureFlaggedRunButton from './FeatureFlaggedRunButton.vue'
const workspaceStore = useWorkspaceStore()
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())

View File

@@ -0,0 +1,252 @@
<template>
<div
:class="buttonClasses"
:style="buttonStyles"
class="experimental-run-button relative overflow-hidden transition-all duration-300"
@click="handleClick"
>
<!-- Animated background for gradient variant -->
<div
v-if="variantName === 'bold-gradient'"
class="absolute inset-0 animate-gradient bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 opacity-90"
/>
<!-- Pulsing animation for animated variant -->
<div
v-if="variantName === 'animated'"
class="absolute inset-0 animate-pulse bg-primary-background opacity-20"
/>
<!-- Sparkle effect for playful variant -->
<div
v-if="variantName === 'playful'"
class="absolute inset-0 overflow-hidden"
>
<i
v-for="i in 3"
:key="i"
:class="sparkleClasses[i - 1]"
class="absolute animate-sparkle text-yellow-300"
:style="sparkleStyles[i - 1]"
>
</i>
</div>
<!-- Button content -->
<div class="relative z-10 flex items-center justify-center gap-2">
<i :class="iconClass" class="text-lg" />
<span :class="labelClasses" :style="labelStyles" class="font-semibold">
{{ label }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { FeatureFlagVariant } from '@/composables/useFeatureFlags'
const props = defineProps<{
variant: FeatureFlagVariant
onClick: () => void
}>()
const variantName = computed(() => props.variant.variant)
const payload = computed(() => props.variant.payload || {})
// Extract styling from payload with defaults
const backgroundColor = computed(
() => (payload.value.backgroundColor as string) || getDefaultColor()
)
const textColor = computed(
() => (payload.value.textColor as string) || getDefaultTextColor()
)
const borderRadius = computed(
() => (payload.value.borderRadius as string) || getDefaultBorderRadius()
)
const icon = computed(() => (payload.value.icon as string) || getDefaultIcon())
const label = computed(() => (payload.value.label as string) || 'Run')
const padding = computed(() => (payload.value.padding as string) || 'px-4 py-2')
// Size matching - should match PrimeVue small button size
const buttonSize = computed(() => {
return 'text-sm' // Match PrimeVue small button
})
function getDefaultColor(): string {
switch (variantName.value) {
case 'bold-gradient':
return 'transparent' // Gradient overlay handles it
case 'animated':
return 'bg-primary-background'
case 'playful':
return 'bg-gradient-to-br from-yellow-400 to-orange-500'
case 'minimal':
return 'bg-white border-2 border-gray-300'
default:
return 'bg-primary-background'
}
}
function getDefaultTextColor(): string {
switch (variantName.value) {
case 'bold-gradient':
return 'text-white'
case 'animated':
return 'text-white'
case 'playful':
return 'text-white'
case 'minimal':
return 'text-gray-800'
default:
return 'text-white'
}
}
function getDefaultBorderRadius(): string {
switch (variantName.value) {
case 'bold-gradient':
return 'rounded-xl'
case 'animated':
return 'rounded-full'
case 'playful':
return 'rounded-2xl'
case 'minimal':
return 'rounded-md'
default:
return 'rounded-lg'
}
}
function getDefaultIcon(): string {
switch (variantName.value) {
case 'bold-gradient':
return 'icon-[lucide--zap]'
case 'animated':
return 'icon-[lucide--rocket]'
case 'playful':
return 'icon-[lucide--sparkles]'
case 'minimal':
return 'icon-[lucide--play]'
default:
return 'icon-[lucide--play]'
}
}
const buttonClasses = computed(() => {
const base = [
'cursor-pointer',
'select-none',
'flex',
'items-center',
'justify-center',
padding.value,
borderRadius.value,
'shadow-lg',
'hover:scale-105',
'active:scale-95',
'transition-transform',
buttonSize.value
]
// Add variant-specific classes
if (variantName.value === 'bold-gradient') {
base.push('text-white', 'font-bold')
} else if (variantName.value === 'animated') {
base.push('text-white', 'font-bold', 'hover:shadow-2xl')
} else if (variantName.value === 'playful') {
base.push('text-white', 'font-bold', 'hover:rotate-1')
} else if (variantName.value === 'minimal') {
base.push('bg-white', 'text-gray-800', 'hover:bg-gray-50')
} else {
base.push(backgroundColor.value, textColor.value)
}
return base.join(' ')
})
const buttonStyles = computed(() => {
const styles: Record<string, string> = {}
// Apply custom styles from payload
if (payload.value.backgroundColor && variantName.value !== 'bold-gradient') {
styles.backgroundColor = backgroundColor.value
}
if (payload.value.textColor) {
styles.color = textColor.value
}
if (payload.value.borderRadius && !borderRadius.value.includes('rounded')) {
styles.borderRadius = borderRadius.value
}
return styles
})
const iconClass = computed(() => icon.value)
// Text color handling - can be a CSS class or a color value
const labelClasses = computed(() => {
// If textColor is a Tailwind class, return it; otherwise it's handled by inline styles
if (textColor.value.startsWith('text-')) {
return textColor.value
}
return ''
})
const labelStyles = computed(() => {
const styles: Record<string, string> = {}
// If textColor is not a Tailwind class, use it as a color value
if (!textColor.value.startsWith('text-')) {
styles.color = textColor.value
}
return styles
})
// Sparkle animation positions for playful variant
const sparkleClasses = ['top-2 left-4', 'top-4 right-6', 'bottom-2 left-1/2']
const sparkleStyles = [
{ animationDelay: '0s', animationDuration: '2s' },
{ animationDelay: '0.5s', animationDuration: '2.5s' },
{ animationDelay: '1s', animationDuration: '3s' }
]
function handleClick() {
props.onClick()
}
</script>
<style scoped>
@keyframes gradient {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
.animate-gradient {
background-size: 200% 200%;
animation: gradient 3s ease infinite;
}
@keyframes sparkle {
0%,
100% {
opacity: 0;
transform: scale(0) rotate(0deg);
}
50% {
opacity: 1;
transform: scale(1) rotate(180deg);
}
}
.animate-sparkle {
animation: sparkle 2s ease-in-out infinite;
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<div class="feature-flagged-run-button">
<!-- Control: Original button -->
<slot v-if="!isExperimentActive" name="control" />
<!-- Experiment: Experimental button -->
<ExperimentalRunButton
v-else-if="variant"
:variant="variant"
:on-click="handleClick"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import type { FeatureFlagVariant } from '@/composables/useFeatureFlags'
import ExperimentalRunButton from './ExperimentalRunButton.vue'
const props = defineProps<{
flagKey?: string
onClick: (e: Event) => void | Promise<void>
}>()
const flagKey = computed(() => props.flagKey || 'demo-run-button-experiment')
const { featureFlag } = useFeatureFlags()
const flagValue = featureFlag<FeatureFlagVariant | boolean | null>(
flagKey.value,
false
)
const variant = computed<FeatureFlagVariant | null>(() => {
const value = flagValue.value
if (
typeof value === 'object' &&
value !== null &&
'variant' in value &&
typeof (value as FeatureFlagVariant).variant === 'string'
) {
return value as FeatureFlagVariant
}
return null
})
const isExperimentActive = computed(() => {
return variant.value !== null
})
const handleClick = () => {
// Create a synthetic event for the onClick handler
const syntheticEvent = new Event('click') as Event
props.onClick(syntheticEvent)
}
</script>

View File

@@ -1,4 +1,4 @@
import { computed, reactive, readonly } from 'vue'
import { computed, reactive, readonly, ref } from 'vue'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
@@ -14,6 +14,17 @@ export enum ServerFeatureFlag {
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled'
}
/**
* Feature flag variant structure for experiments
*/
export interface FeatureFlagVariant {
variant: string
payload?: Record<string, unknown>
}
// Demo mode: allows manual override for demonstration
const demoOverrides = ref<Record<string, unknown>>({})
/**
* Composable for reactive access to server-side feature flags
*/
@@ -51,7 +62,35 @@ export function useFeatureFlags() {
})
const featureFlag = <T = unknown>(featurePath: string, defaultValue?: T) =>
computed(() => api.getServerFeature(featurePath, defaultValue))
computed(() => {
// Check demo overrides first
if (demoOverrides.value[featurePath] !== undefined) {
return demoOverrides.value[featurePath] as T
}
// Check remote config (from /api/features) - convert hyphens to underscores for lookup
const remoteConfigKey = featurePath.replace(/-/g, '_')
const remoteValue = (remoteConfig.value as Record<string, unknown>)[
remoteConfigKey
]
if (remoteValue !== undefined) {
return remoteValue as T
}
// Fall back to server feature flags (WebSocket) - try both hyphen and underscore versions
const wsValue = api.getServerFeature(featurePath, undefined)
if (wsValue !== undefined) {
return wsValue as T
}
// Try underscore version for WebSocket flags
const wsValueUnderscore = api.getServerFeature(
featurePath.replace(/-/g, '_'),
undefined
)
if (wsValueUnderscore !== undefined) {
return wsValueUnderscore as T
}
// Return default if nothing found
return defaultValue as T
})
return {
flags: readonly(flags),