diff --git a/apps/web/components/model-config-selector.test.tsx b/apps/web/components/model-config-selector.test.tsx index e4eb5855f3..f154225520 100644 --- a/apps/web/components/model-config-selector.test.tsx +++ b/apps/web/components/model-config-selector.test.tsx @@ -1,5 +1,5 @@ -import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ModelConfigSelector } from "@/components/model-config-selector"; @@ -8,6 +8,15 @@ afterEach(() => { }); describe("ModelConfigSelector", () => { + const effortSectionTestId = "config-option-section-effort"; + const effortTriggerTestId = "config-option-trigger-effort"; + const modelSettingsButtonName = "Model settings"; + const makeModelOptions = (count: number) => + Array.from({ length: count }, (_, index) => ({ + id: `model-${index + 1}`, + name: `Model ${index + 1}`, + })); + it("passes custom trigger classes to the button", () => { render( { />, ); - expect(screen.getByRole("button", { name: "Model settings" }).className).toContain( + expect(screen.getByRole("button", { name: modelSettingsButtonName }).className).toContain( "max-w-[56vw]", ); }); + + it("opens extra config options from compact sub-selector rows", () => { + const onConfigChange = vi.fn(); + + render( + {}} + onConfigChange={onConfigChange} + configOptions={[ + { + type: "select", + id: "model", + name: "Model", + currentValue: "sonnet", + category: "model", + options: [{ value: "sonnet", name: "Sonnet" }], + }, + { + type: "select", + id: "effort", + name: "Effort", + currentValue: "medium", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }, + ]} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName })); + + const effortTrigger = screen.getByTestId(effortTriggerTestId); + expect(effortTrigger.textContent).toContain("Effort"); + expect(effortTrigger.textContent).toContain("Medium"); + expect(screen.queryByTestId(effortSectionTestId)).toBeNull(); + + fireEvent.click(effortTrigger); + + const effortSection = screen.getByTestId(effortSectionTestId); + expect(effortSection).not.toBeNull(); + const backButton = screen.getByRole("button", { name: /back to model settings from effort/i }); + expect(document.activeElement).toBe(backButton); + + fireEvent.click(backButton); + + expect(screen.queryByTestId(effortSectionTestId)).toBeNull(); + expect(document.activeElement).toBe(screen.getByTestId(effortTriggerTestId)); + + fireEvent.click(screen.getByTestId(effortTriggerTestId)); + const reopenedEffortSection = screen.getByTestId(effortSectionTestId); + fireEvent.click(within(reopenedEffortSection).getByRole("button", { name: "High" })); + + expect(onConfigChange).toHaveBeenCalledWith("effort", "high"); + expect(screen.queryByTestId(effortSectionTestId)).toBeNull(); + }); + + it("hides the model filter when there are five or fewer models", () => { + render( + {}} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName })); + + expect(screen.queryByPlaceholderText("Filter models...")).toBeNull(); + }); + + it("shows the model filter when there are more than five models", () => { + render( + {}} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName })); + + expect(screen.getByPlaceholderText("Filter models...")).not.toBeNull(); + }); }); diff --git a/apps/web/components/model-config-selector.tsx b/apps/web/components/model-config-selector.tsx index 2decb1b62e..f3bb4d8cc0 100644 --- a/apps/web/components/model-config-selector.tsx +++ b/apps/web/components/model-config-selector.tsx @@ -1,7 +1,7 @@ "use client"; -import { memo, useState } from "react"; -import { IconCheck, IconChevronDown } from "@tabler/icons-react"; +import { memo, useEffect, useRef, useState } from "react"; +import { IconCheck, IconChevronDown, IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { Button } from "@kandev/ui/button"; @@ -147,37 +147,177 @@ function ModelRow({ ); } -function ConfigOptionSection({ +function ConfigOptionTrigger({ option, + onSelect, + triggerRef, +}: { + option: SelectConfigOption; + onSelect: () => void; + triggerRef?: (element: HTMLButtonElement | null) => void; +}) { + return ( + + ); +} + +function ConfigOptionSubSelector({ + option, + onBack, onChange, }: { option: SelectConfigOption; + onBack: () => void; onChange?: (configId: string, value: string) => void; }) { return ( -
-
- {option.name} -
-
- {option.options.map((item) => ( - - ))} -
+
+ + +
+ {option.options.map((item) => ( + + ))} +
+
); } +type ModelConfigSelectorContentProps = { + activeConfig: SelectConfigOption | undefined; + modelOptions: ModelSelectorOption[]; + currentModelValue: string; + extraConfigOptions: SelectConfigOption[]; + onModelSelect: (value: string) => void; + onConfigSelect: (configId: string) => void; + onConfigBack: () => void; + onConfigChange?: (configId: string, value: string) => void; +}; + +function ModelConfigSelectorContent({ + activeConfig, + modelOptions, + currentModelValue, + extraConfigOptions, + onModelSelect, + onConfigSelect, + onConfigBack, + onConfigChange, +}: ModelConfigSelectorContentProps) { + const pendingFocusConfigId = useRef(null); + const triggerRefs = useRef>({}); + const showModelFilter = modelOptions.length > 5; + + useEffect(() => { + if (activeConfig) return; + const configId = pendingFocusConfigId.current; + if (!configId) return; + pendingFocusConfigId.current = null; + triggerRefs.current[configId]?.focus(); + }, [activeConfig]); + + const returnToConfigTrigger = () => { + if (activeConfig) { + pendingFocusConfigId.current = activeConfig.id; + } + onConfigBack(); + }; + + if (activeConfig) { + return ( + + ); + } + + return ( + <> + + {showModelFilter && } + + No models found. + + {modelOptions.map((model) => ( + + ))} + + + + {extraConfigOptions.length > 0 && ( + <> + + +
+ {extraConfigOptions.map((option) => ( + onConfigSelect(option.id)} + triggerRef={(element) => { + triggerRefs.current[option.id] = element; + }} + /> + ))} +
+
+ + )} + + ); +} + export type ModelConfigSelectorProps = { modelOptions: ModelSelectorOption[]; currentModel: string | null; @@ -206,9 +346,11 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({ triggerClassName: customTriggerClassName, }: ModelConfigSelectorProps) { const [open, setOpen] = useState(false); + const [activeConfigId, setActiveConfigId] = useState(null); const selectConfigOptions = usableConfigOptions(configOptions); const modelConfig = selectConfigOptions.find(isModelConfigOption); const extraConfigOptions = selectConfigOptions.filter((option) => !isModelConfigOption(option)); + const activeConfig = extraConfigOptions.find((option) => option.id === activeConfigId); const currentModelValue = modelConfig?.currentValue || currentModel || ""; const label = resolveTriggerLabel(modelOptions, currentModel, modelConfig, configOptions); @@ -225,9 +367,15 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({ variant === "compact" ? "h-7 max-w-[min(18rem,70vw)] cursor-pointer gap-1 px-2 text-xs hover:bg-muted/40" : "w-full justify-between font-normal cursor-pointer"; + const onOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setActiveConfigId(null); + } + }; return ( - +