diff --git a/ui/admin/app/components/agent/AgentForm.tsx b/ui/admin/app/components/agent/AgentForm.tsx
index 93b10a3b..250a9165 100644
--- a/ui/admin/app/components/agent/AgentForm.tsx
+++ b/ui/admin/app/components/agent/AgentForm.tsx
@@ -5,24 +5,17 @@ import { useForm } from "react-hook-form";
import useSWR from "swr";
import { z } from "zod";
-import { ModelUsage } from "~/lib/model/models";
+import { Model, ModelUsage } from "~/lib/model/models";
import { ModelApiService } from "~/lib/service/api/modelApiService";
import { TypographyH4 } from "~/components/Typography";
+import { ComboBox } from "~/components/composed/ComboBox";
import {
ControlledAutosizeTextarea,
ControlledCustomInput,
ControlledInput,
} from "~/components/form/controlledInputs";
import { Form } from "~/components/ui/form";
-import {
- Select,
- SelectContent,
- SelectEmptyItem,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "~/components/ui/select";
const formSchema = z.object({
name: z.string().min(1, {
@@ -86,6 +79,7 @@ export function AgentForm({ agent, onSubmit, onChange }: AgentFormProps) {
onSubmit?.({ ...agent, ...values })
);
+ const modelOptionsByGroup = getModelOptionsByModelProvider(models);
return (
);
+
+ function getModelOptionsByModelProvider(models: Model[]) {
+ const byModelProviderGroups = models.reduce(
+ (acc, model) => {
+ acc[model.modelProvider] = acc[model.modelProvider] || [];
+ acc[model.modelProvider].push(model);
+ return acc;
+ },
+ {} as Record
+ );
+
+ return Object.entries(byModelProviderGroups).map(
+ ([modelProvider, models]) => {
+ const sorted = models.sort((a, b) =>
+ (a.name ?? "").localeCompare(b.name ?? "")
+ );
+ return {
+ heading: modelProvider,
+ value: sorted,
+ };
+ }
+ );
+ }
}
diff --git a/ui/admin/app/components/composed/ComboBox.tsx b/ui/admin/app/components/composed/ComboBox.tsx
new file mode 100644
index 00000000..8cae2c60
--- /dev/null
+++ b/ui/admin/app/components/composed/ComboBox.tsx
@@ -0,0 +1,178 @@
+"use client";
+
+import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
+import { ReactNode, useState } from "react";
+
+import { Button } from "~/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "~/components/ui/command";
+import { Drawer, DrawerContent, DrawerTrigger } from "~/components/ui/drawer";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "~/components/ui/popover";
+import { useIsMobile } from "~/hooks/use-mobile";
+
+type BaseOption = {
+ id: string;
+ name?: string | undefined;
+};
+
+type GroupedOption = {
+ heading: string;
+ value: T[];
+};
+
+type ComboBoxProps = {
+ allowClear?: boolean;
+ clearLabel?: ReactNode;
+ onChange: (option: T | null) => void;
+ options: T[] | GroupedOption[];
+ placeholder?: string;
+ value?: T | null;
+};
+
+export function ComboBox({
+ disabled,
+ placeholder,
+ value,
+ ...props
+}: {
+ disabled?: boolean;
+} & ComboBoxProps) {
+ const [open, setOpen] = useState(false);
+ const isMobile = useIsMobile();
+
+ if (!isMobile) {
+ return (
+
+ {renderButtonContent()}
+
+
+
+
+ );
+ }
+
+ return (
+
+ {renderButtonContent()}
+
+
+
+
+
+
+ );
+
+ function renderButtonContent() {
+ return (
+ }
+ variant="outline"
+ className="px-3 w-full font-normal justify-start rounded-sm"
+ classNames={{
+ content: "w-full justify-between",
+ }}
+ >
+
+ {value ? value.name : placeholder}
+
+
+ );
+ }
+}
+
+function ComboBoxList({
+ allowClear,
+ clearLabel,
+ onChange,
+ options,
+ placeholder = "Filter...",
+ setOpen,
+ value,
+}: { setOpen: (open: boolean) => void } & ComboBoxProps) {
+ const isGrouped = options.every((option) => "heading" in option);
+ return (
+
+
+
+ No results found.
+ {allowClear && (
+
+ {
+ onChange(null);
+ setOpen(false);
+ }}
+ >
+ {clearLabel ?? "Clear Selection"}
+
+
+ )}
+ {isGrouped
+ ? renderGroupedOptions(options)
+ : renderUngroupedOptions(options)}
+
+
+ );
+
+ function renderGroupedOptions(items: GroupedOption[]) {
+ return items.map((group) => (
+
+ {group.value.map((option) => (
+ {
+ const match =
+ group.value.find((opt) => opt.name === name) ||
+ null;
+ onChange(match);
+ setOpen(false);
+ }}
+ className="justify-between"
+ >
+ {option.name || option.id}{" "}
+ {value?.id === option.id && (
+
+ )}
+
+ ))}
+
+ ));
+ }
+
+ function renderUngroupedOptions(items: T[]) {
+ return (
+
+ {items.map((option) => (
+ {
+ const match =
+ items.find((opt) => opt.name === name) || null;
+ onChange(match);
+ setOpen(false);
+ }}
+ className="justify-between"
+ >
+ {option.name || option.id}{" "}
+ {value?.id === option.id && (
+
+ )}
+
+ ))}
+
+ );
+ }
+}
diff --git a/ui/admin/app/components/ui/button.tsx b/ui/admin/app/components/ui/button.tsx
index 5d8b1354..79e0f8e2 100644
--- a/ui/admin/app/components/ui/button.tsx
+++ b/ui/admin/app/components/ui/button.tsx
@@ -50,6 +50,9 @@ export type ButtonProps = React.ButtonHTMLAttributes &
loading?: boolean;
startContent?: React.ReactNode;
endContent?: React.ReactNode;
+ classNames?: {
+ content?: string;
+ };
};
const Button = React.forwardRef(
@@ -64,6 +67,7 @@ const Button = React.forwardRef(
startContent,
endContent,
children,
+ classNames,
...props
},
ref
@@ -93,7 +97,12 @@ const Button = React.forwardRef(
{endContent}
) : (
-
+
{startContent}
{children}
{endContent}
diff --git a/ui/admin/app/components/ui/drawer.tsx b/ui/admin/app/components/ui/drawer.tsx
new file mode 100644
index 00000000..b5278ece
--- /dev/null
+++ b/ui/admin/app/components/ui/drawer.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import * as React from "react";
+import { Drawer as DrawerPrimitive } from "vaul";
+
+import { cn } from "~/lib/utils";
+
+const Drawer = ({
+ shouldScaleBackground = true,
+ ...props
+}: React.ComponentProps
) => (
+
+);
+Drawer.displayName = "Drawer";
+
+const DrawerTrigger = DrawerPrimitive.Trigger;
+
+const DrawerPortal = DrawerPrimitive.Portal;
+
+const DrawerClose = DrawerPrimitive.Close;
+
+const DrawerOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
+
+const DrawerContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+));
+DrawerContent.displayName = "DrawerContent";
+
+const DrawerHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DrawerHeader.displayName = "DrawerHeader";
+
+const DrawerFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DrawerFooter.displayName = "DrawerFooter";
+
+const DrawerTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
+
+const DrawerDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
+
+export {
+ Drawer,
+ DrawerPortal,
+ DrawerOverlay,
+ DrawerTrigger,
+ DrawerClose,
+ DrawerContent,
+ DrawerHeader,
+ DrawerFooter,
+ DrawerTitle,
+ DrawerDescription,
+};
diff --git a/ui/admin/package.json b/ui/admin/package.json
index a585b108..379ba12c 100644
--- a/ui/admin/package.json
+++ b/ui/admin/package.json
@@ -60,6 +60,7 @@
"swr": "^2.2.5",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
+ "vaul": "^1.1.0",
"zod": "^3.23.8"
},
"devDependencies": {
diff --git a/ui/admin/pnpm-lock.yaml b/ui/admin/pnpm-lock.yaml
index 68e5723d..2d0697b8 100644
--- a/ui/admin/pnpm-lock.yaml
+++ b/ui/admin/pnpm-lock.yaml
@@ -155,6 +155,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.14)
+ vaul:
+ specifier: ^1.1.0
+ version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
zod:
specifier: ^3.23.8
version: 3.23.8
@@ -4834,6 +4837,12 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
+ vaul@1.1.2:
+ resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
+ peerDependencies:
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+
vfile-message@3.1.4:
resolution: {integrity: sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==}
@@ -10424,6 +10433,15 @@ snapshots:
vary@1.1.2: {}
+ vaul@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@radix-ui/react-dialog': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ transitivePeerDependencies:
+ - '@types/react'
+ - '@types/react-dom'
+
vfile-message@3.1.4:
dependencies:
'@types/unist': 2.0.11