From ae53d23055b764fd2d87decdf47835a7c719d8d7 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Fri, 5 Jun 2026 16:41:48 -0400 Subject: [PATCH 1/2] feat(feature-flags): show dropdown for string flags with known variants When the platform API returns variant values for a string feature flag, render a Dropdown selector instead of a free-text input in the developer panel. Falls back to the existing text input when values are unavailable. --- .../components/panels/feature-flags-panel.tsx | 81 +++++++++++++++---- .../lib/feature-flags/feature-flag-catalog.ts | 1 + 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/apps/web/src/domains/settings/components/panels/feature-flags-panel.tsx b/apps/web/src/domains/settings/components/panels/feature-flags-panel.tsx index d88132da0ac..34fe2d3c228 100644 --- a/apps/web/src/domains/settings/components/panels/feature-flags-panel.tsx +++ b/apps/web/src/domains/settings/components/panels/feature-flags-panel.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { DetailCard } from "@/components/detail-card"; import { assistantsActiveRetrieveOptions } from "@/generated/api/@tanstack/react-query.gen"; +import { client } from "@/generated/api/client.gen"; import { fetchAssistantFlagValues } from "@/hooks/use-assistant-feature-flag-sync"; import { useFlagQueryFreshness } from "@/lib/backwards-compat/flag-query-freshness"; import { @@ -16,9 +17,26 @@ import { import { assistantFlagValuesQueryKey } from "@/lib/sync/query-tags"; import { useAssistantFeatureFlagStore } from "@/stores/assistant-feature-flag-store"; import { useClientFeatureFlagStore } from "@/stores/client-feature-flag-store"; +import { Dropdown } from "@vellumai/design-library/components/dropdown"; import { Tag, type TagTone } from "@vellumai/design-library/components/tag"; import { Toggle } from "@vellumai/design-library/components/toggle"; +interface FlagDefinitionResponse { + key: string; + type: "boolean" | "string"; + values?: string[]; +} + +async function fetchFlagDefinitions(): Promise { + const { data, response } = await client.get< + FlagDefinitionResponse[], + Record, + false + >({ url: "/v1/feature-flags/", throwOnError: false }); + if (!response?.ok) return []; + return (data as FlagDefinitionResponse[]) ?? []; +} + const SCOPE_TONE: Record = { client: "warning", assistant: "positive", @@ -42,6 +60,7 @@ type FlagDisplayEntry = description: string; value: string; defaultValue: string; + values?: string[]; }; export function FeatureFlagsPanel() { @@ -61,6 +80,23 @@ export function FeatureFlagsPanel() { retry: 1, }); + const { data: definitions } = useQuery({ + queryKey: ["feature-flag-definitions"], + queryFn: fetchFlagDefinitions, + staleTime: 5 * 60 * 1000, + }); + + const valuesMap = useMemo(() => { + const map = new Map(); + if (!definitions) return map; + for (const def of definitions) { + if (def.type === "string" && def.values?.length) { + map.set(def.key, def.values); + } + } + return map; + }, [definitions]); + const [searchText, setSearchText] = useState(""); const clientState = useClientFeatureFlagStore(); const assistantState = useAssistantFeatureFlagStore(); @@ -104,13 +140,14 @@ export function FeatureFlagsPanel() { description: flag.description, value, defaultValue: flag.defaultEnabled, + values: valuesMap.get(flag.key), }); } } return entries.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }), ); - }, [clientState, assistantState]); + }, [clientState, assistantState, valuesMap]); const filteredFlags = useMemo(() => { if (!searchText.trim()) { @@ -253,22 +290,26 @@ function StringFlagRow({ setLocalValue(flag.value); }, [flag.value]); - const handleBlur = () => { - if (localValue === flag.value) return; + const commitValue = (next: string) => { + if (next === flag.value) return; if (scopeIncludes(flag.scope, "client")) { - clientSetStringFlag(flag.storeKey, localValue); + clientSetStringFlag(flag.storeKey, next); } if (scopeIncludes(flag.scope, "assistant")) { - assistantSetStringFlag(flag.storeKey, localValue, assistantId); + assistantSetStringFlag(flag.storeKey, next, assistantId); } }; + const handleBlur = () => commitValue(localValue); + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.currentTarget.blur(); } }; + const hasDropdown = flag.values && flag.values.length > 0; + return (
@@ -281,15 +322,27 @@ function StringFlagRow({ {flag.description} - setLocalValue(e.target.value)} - onBlur={handleBlur} - onKeyDown={handleKeyDown} - className="w-full rounded-lg border border-[var(--border-base)] bg-[var(--surface-default)] px-3 py-1.5 text-body-small-default text-[var(--content-default)] placeholder:text-[var(--content-tertiary)] focus:border-[var(--border-focus)] focus:outline-none" - placeholder={flag.defaultValue || "Enter value..."} - /> + {hasDropdown ? ( + { + setLocalValue(next); + commitValue(next); + }} + options={flag.values!.map((v) => ({ value: v, label: v }))} + aria-label={`${flag.label} value`} + /> + ) : ( + setLocalValue(e.target.value)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + className="w-full rounded-lg border border-[var(--border-base)] bg-[var(--surface-default)] px-3 py-1.5 text-body-small-default text-[var(--content-default)] placeholder:text-[var(--content-tertiary)] focus:border-[var(--border-focus)] focus:outline-none" + placeholder={flag.defaultValue || "Enter value..."} + /> + )}
Default: diff --git a/apps/web/src/lib/feature-flags/feature-flag-catalog.ts b/apps/web/src/lib/feature-flags/feature-flag-catalog.ts index ccc80b01226..842ce8d4fc9 100644 --- a/apps/web/src/lib/feature-flags/feature-flag-catalog.ts +++ b/apps/web/src/lib/feature-flags/feature-flag-catalog.ts @@ -17,6 +17,7 @@ export interface FlagDefinition { label: string; description: string; defaultEnabled: boolean | string; + values?: string[]; } const flags = registry.flags as FlagDefinition[]; From decc19b12b9fee0eb1d8a2f9e76af3c276417088 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Fri, 5 Jun 2026 16:46:11 -0400 Subject: [PATCH 2/2] fix: gate definitions query on org readiness, fall back to text input for unknown values - Gate the feature-flag-definitions useQuery on useIsOrgReady() to prevent caching an empty response when the org header isn't available yet - Fall back to free-text input when the current flag value isn't in the known variants list (e.g. manual overrides or stale definitions) --- .../settings/components/panels/feature-flags-panel.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/domains/settings/components/panels/feature-flags-panel.tsx b/apps/web/src/domains/settings/components/panels/feature-flags-panel.tsx index 34fe2d3c228..0f067f8ba78 100644 --- a/apps/web/src/domains/settings/components/panels/feature-flags-panel.tsx +++ b/apps/web/src/domains/settings/components/panels/feature-flags-panel.tsx @@ -6,6 +6,7 @@ import { DetailCard } from "@/components/detail-card"; import { assistantsActiveRetrieveOptions } from "@/generated/api/@tanstack/react-query.gen"; import { client } from "@/generated/api/client.gen"; import { fetchAssistantFlagValues } from "@/hooks/use-assistant-feature-flag-sync"; +import { useIsOrgReady } from "@/hooks/use-is-org-ready"; import { useFlagQueryFreshness } from "@/lib/backwards-compat/flag-query-freshness"; import { ALL_FLAGS, @@ -80,9 +81,11 @@ export function FeatureFlagsPanel() { retry: 1, }); + const isOrgReady = useIsOrgReady(); const { data: definitions } = useQuery({ queryKey: ["feature-flag-definitions"], queryFn: fetchFlagDefinitions, + enabled: isOrgReady, staleTime: 5 * 60 * 1000, }); @@ -308,7 +311,8 @@ function StringFlagRow({ } }; - const hasDropdown = flag.values && flag.values.length > 0; + const hasDropdown = + flag.values && flag.values.length > 0 && flag.values.includes(flag.value); return (