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 (
@@ -125,30 +119,42 @@ export function AgentForm({ agent, onSubmit, onChange }: AgentFormProps) { name="model" > {({ field: { ref: _, ...field } }) => ( - + m.id === field.value)} + onChange={(value) => + field.onChange(value?.id ?? "") + } + options={modelOptionsByGroup} + /> )} ); + + 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 ( + + ); + } +} + +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