Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ 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 { useIsOrgReady } from "@/hooks/use-is-org-ready";
import { useFlagQueryFreshness } from "@/lib/backwards-compat/flag-query-freshness";
import {
ALL_FLAGS,
Expand All @@ -16,9 +18,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<FlagDefinitionResponse[]> {
const { data, response } = await client.get<
FlagDefinitionResponse[],
Record<string, unknown>,
false
>({ url: "/v1/feature-flags/", throwOnError: false });
Comment thread
noanflaherty marked this conversation as resolved.
if (!response?.ok) return [];
return (data as FlagDefinitionResponse[]) ?? [];
}

const SCOPE_TONE: Record<SingleScope, TagTone> = {
client: "warning",
assistant: "positive",
Expand All @@ -42,6 +61,7 @@ type FlagDisplayEntry =
description: string;
value: string;
defaultValue: string;
values?: string[];
};

export function FeatureFlagsPanel() {
Expand All @@ -61,6 +81,25 @@ export function FeatureFlagsPanel() {
retry: 1,
});

const isOrgReady = useIsOrgReady();
const { data: definitions } = useQuery({
queryKey: ["feature-flag-definitions"],
queryFn: fetchFlagDefinitions,
enabled: isOrgReady,
staleTime: 5 * 60 * 1000,
});
Comment thread
noanflaherty marked this conversation as resolved.

const valuesMap = useMemo(() => {
const map = new Map<string, string[]>();
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();
Expand Down Expand Up @@ -104,13 +143,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()) {
Expand Down Expand Up @@ -253,22 +293,27 @@ 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<HTMLInputElement>) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
};

const hasDropdown =
flag.values && flag.values.length > 0 && flag.values.includes(flag.value);

return (
<div className="flex items-start gap-3 py-3">
<div className="min-w-0 flex-1 space-y-1.5">
Expand All @@ -281,15 +326,27 @@ function StringFlagRow({
<span className="block text-body-small-default text-[var(--content-tertiary)]">
{flag.description}
</span>
<input
type="text"
value={localValue}
onChange={(e) => 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 ? (
<Dropdown
value={flag.value}
onChange={(next) => {
Comment thread
noanflaherty marked this conversation as resolved.
setLocalValue(next);
commitValue(next);
}}
options={flag.values!.map((v) => ({ value: v, label: v }))}
aria-label={`${flag.label} value`}
/>
) : (
<input
type="text"
value={localValue}
onChange={(e) => 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..."}
/>
)}
<div className="flex items-center gap-1">
<span className="text-body-small-default text-[var(--content-tertiary)]">
Default:
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/feature-flags/feature-flag-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface FlagDefinition {
label: string;
description: string;
defaultEnabled: boolean | string;
values?: string[];
}

const flags = registry.flags as FlagDefinition[];
Expand Down