Files
kandev/apps/web/components/model-config-selector.tsx
T
2026-08-09 23:21:39 +01:00

616 lines
19 KiB
TypeScript

"use client";
import { memo, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { IconCheck, IconChevronDown, IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { Button } from "@kandev/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@kandev/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { ScrollArea } from "@kandev/ui/scroll-area";
import { Separator } from "@kandev/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
export type ModelSelectorOption = {
id: string;
name: string;
description?: string;
usageMultiplier?: string;
/** When true the model is unavailable ("gone") — rendered greyed out and
* not selectable, with `disabledReason` shown in a tooltip. */
disabled?: boolean;
disabledReason?: string;
};
export type DynamicConfigOption = {
type: string;
id: string;
name: string;
description?: string;
currentValue: string;
category?: string;
options?: { value: string; name: string; description?: string }[];
};
export type SelectConfigOption = DynamicConfigOption & {
options: { value: string; name: string; description?: string }[];
};
type TriggerLabelOptions = {
summary: "changed";
configBaseline?: Record<string, string>;
/** Appended to the trigger's model label (e.g. "(fallback)"). */
currentModelSuffix?: string;
};
type TriggerDetail = {
id: string;
name: string;
value: string;
};
const MODEL_CONFIG_CATEGORY = "model";
const MODE_CONFIG_CATEGORY = "mode";
export function isModelConfigOption(option: Pick<DynamicConfigOption, "id" | "category">): boolean {
return option.id === MODEL_CONFIG_CATEGORY || option.category === MODEL_CONFIG_CATEGORY;
}
export function isModeConfigOption(option: Pick<DynamicConfigOption, "id" | "category">): boolean {
return option.id === MODE_CONFIG_CATEGORY || option.category === MODE_CONFIG_CATEGORY;
}
export function usableConfigOptions(
options: DynamicConfigOption[] | undefined,
): SelectConfigOption[] {
return (options ?? []).filter(
(option): option is SelectConfigOption =>
option.type === "select" &&
!isModeConfigOption(option) &&
Array.isArray(option.options) &&
option.options.length > 0,
);
}
export function configOptionToModelOptions(
option: SelectConfigOption | undefined,
): ModelSelectorOption[] {
if (!option) return [];
const seen = new Set<string>();
return option.options.flatMap((item) => {
if (seen.has(item.value)) return [];
seen.add(item.value);
return [
{
id: item.value,
name: item.name,
description: item.description ?? (item.value !== item.name ? item.value : undefined),
},
];
});
}
function currentOptionValue(option: DynamicConfigOption) {
return option.options?.find((item) => item.value === option.currentValue);
}
function currentOptionName(option: DynamicConfigOption): string {
return currentOptionValue(option)?.name ?? option.currentValue;
}
export function displayModelName(
modelOptions: ModelSelectorOption[],
currentModel: string,
): string {
return modelOptions.find((m) => m.id === currentModel)?.name ?? currentModel;
}
export function triggerLabel(
modelOptions: ModelSelectorOption[],
currentModel: string,
configOptions: DynamicConfigOption[],
options?: TriggerLabelOptions,
): string {
const modelConfig = configOptions.find(isModelConfigOption);
const modelValue =
(modelConfig ? currentOptionName(modelConfig) : displayModelName(modelOptions, currentModel)) +
(options?.currentModelSuffix ?? "");
const baseline = options?.configBaseline;
const extras = configOptions
.filter((option) => !isModelConfigOption(option))
.filter(
(option) =>
!options ||
baseline === undefined ||
!Object.hasOwn(baseline, option.id) ||
baseline[option.id] !== option.currentValue,
)
.map(currentOptionName)
.filter(Boolean);
return [modelValue, ...extras].join(" / ");
}
export function resolveTriggerLabel(
modelOptions: ModelSelectorOption[],
currentModel: string | null,
modelConfig: DynamicConfigOption | undefined,
configOptions: DynamicConfigOption[],
options?: TriggerLabelOptions,
): string {
const modelValue = currentModel || modelConfig?.currentValue;
if (!modelValue) return "";
return triggerLabel(modelOptions, modelValue, configOptions, options);
}
function triggerDetails(
modelOptions: ModelSelectorOption[],
currentModel: string | null,
modelConfig: SelectConfigOption | undefined,
extraConfigOptions: SelectConfigOption[],
t: TFunction,
): TriggerDetail[] {
let modelValue = "";
if (modelConfig) {
modelValue = currentOptionName(modelConfig);
} else if (currentModel) {
modelValue = displayModelName(modelOptions, currentModel);
}
const details = modelValue
? [
{
id: modelConfig?.id || MODEL_CONFIG_CATEGORY,
name: modelConfig?.name || t("common:model"),
value: modelValue,
},
]
: [];
return details.concat(
extraConfigOptions.map((option) => ({
id: option.id,
name: option.name || option.id,
value: currentOptionName(option),
})),
);
}
function ModelRow({
model,
selected,
onSelect,
}: {
model: ModelSelectorOption;
selected: boolean;
onSelect: (value: string) => void;
}) {
const item = (
<CommandItem
value={model.id}
keywords={[model.name, model.description ?? "", model.id]}
onSelect={() => !model.disabled && onSelect(model.id)}
disabled={model.disabled}
className={cn("relative pr-7", model.disabled && "opacity-40 cursor-not-allowed")}
>
<div className="flex min-w-0 flex-1 items-center">
<div className="min-w-0 flex-1">
<div className="truncate">{model.name}</div>
{model.description && (
<div className="truncate text-xs text-muted-foreground" title={model.description}>
{model.description}
</div>
)}
</div>
{model.usageMultiplier && (
<span className="shrink-0 text-xs text-muted-foreground">{model.usageMultiplier}</span>
)}
</div>
<IconCheck
className={cn("absolute right-2 h-4 w-4", selected ? "opacity-100" : "opacity-0")}
/>
</CommandItem>
);
// cmdk's CommandItem swallows pointer events with no native tooltip slot;
// wrap disabled items in a Tooltip trigger so the gone-reason shows. The
// CommandItem is disabled (unfocusable), so the wrapper itself must be
// focusable and carry an accessible name for keyboard users to reach the
// reason.
if (model.disabled && model.disabledReason) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div
tabIndex={0}
aria-label={`${model.name}: ${model.disabledReason}`}
className="rounded outline-none focus-visible:ring-2 focus-visible:ring-ring/35"
>
{item}
</div>
</TooltipTrigger>
<TooltipContent side="right">{model.disabledReason}</TooltipContent>
</Tooltip>
);
}
return item;
}
function ConfigOptionTrigger({
option,
onSelect,
triggerRef,
}: {
option: SelectConfigOption;
onSelect: () => void;
triggerRef?: (element: HTMLButtonElement | null) => void;
}) {
return (
<button
type="button"
ref={triggerRef}
data-testid={`config-option-trigger-${option.id}`}
className="flex min-h-9 w-full cursor-pointer items-center justify-between gap-3 rounded-md px-2.5 py-2 text-left text-xs/relaxed hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:outline-none"
onClick={onSelect}
>
<span className="min-w-0 flex-1">
<span className="block font-medium">{option.name}</span>
</span>
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
<span className="truncate">{currentOptionName(option)}</span>
<IconChevronRight className="h-3.5 w-3.5 shrink-0" />
</span>
</button>
);
}
function ConfigOptionSubSelector({
option,
onBack,
onChange,
}: {
option: SelectConfigOption;
onBack: () => void;
onChange?: (configId: string, value: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex min-h-0 flex-col gap-2">
<button
type="button"
aria-label={t("agents:backToModelSettings", { name: option.name })}
autoFocus
className="flex min-h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-xs/relaxed hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:outline-none"
onClick={onBack}
>
<IconChevronLeft className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span className="block truncate font-medium">{option.name}</span>
{option.description && (
<span className="block whitespace-normal text-muted-foreground">
{option.description}
</span>
)}
</span>
</button>
<div
className="max-h-[min(18rem,calc(100dvh-8rem))] overflow-y-auto overscroll-contain pr-2"
data-testid={`config-option-section-${option.id}`}
>
<div className="space-y-1">
{option.options.map((item, index) => {
const descriptionId = item.description
? `config-option-value-description-${option.id}-${index}`
: undefined;
return (
<Button
key={item.value}
type="button"
aria-label={item.name}
aria-describedby={descriptionId}
variant={item.value === option.currentValue ? "secondary" : "ghost"}
size="sm"
className="h-auto min-h-9 w-full min-w-0 cursor-pointer justify-start px-2 py-2 text-left"
disabled={!onChange}
onClick={() => {
onChange?.(option.id, item.value);
onBack();
}}
>
<span className="min-w-0 flex-1">
<span className="block truncate">{item.name}</span>
{item.description && (
<span
id={descriptionId}
className="block whitespace-normal text-xs text-muted-foreground"
>
{item.description}
</span>
)}
</span>
<IconCheck
className={cn(
"ml-auto h-4 w-4 shrink-0",
item.value === option.currentValue ? "opacity-100" : "opacity-0",
)}
/>
</Button>
);
})}
</div>
</div>
</div>
);
}
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 { t } = useTranslation();
const pendingFocusConfigId = useRef<string | null>(null);
const triggerRefs = useRef<Record<string, HTMLButtonElement | null>>({});
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 (
<ConfigOptionSubSelector
option={activeConfig}
onBack={returnToConfigTrigger}
onChange={onConfigChange}
/>
);
}
return (
<>
<Command>
{showModelFilter && <CommandInput placeholder={t("agents:filterModels")} className="h-8" />}
<CommandList className="max-h-60">
<CommandEmpty>{t("agents:noModelsFound")}</CommandEmpty>
<CommandGroup heading={t("agents:modelHeading")}>
{modelOptions.map((model) => (
<ModelRow
key={model.id}
model={model}
selected={model.id === currentModelValue}
onSelect={onModelSelect}
/>
))}
</CommandGroup>
</CommandList>
</Command>
{extraConfigOptions.length > 0 && (
<>
<Separator />
<ScrollArea className="max-h-40 pr-2">
<div className="space-y-1">
{extraConfigOptions.map((option) => (
<ConfigOptionTrigger
key={option.id}
option={option}
onSelect={() => onConfigSelect(option.id)}
triggerRef={(element) => {
triggerRefs.current[option.id] = element;
}}
/>
))}
</div>
</ScrollArea>
</>
)}
</>
);
}
export type ModelConfigSelectorProps = {
modelOptions: ModelSelectorOption[];
currentModel: string | null;
configOptions?: DynamicConfigOption[];
onModelChange: (modelId: string) => void;
onConfigChange?: (configId: string, value: string) => void;
disabled?: boolean;
placeholder?: string;
ariaLabel?: string;
variant?: "compact" | "field";
popoverSide?: "top" | "bottom";
triggerClassName?: string;
triggerSummary?: "all" | "changed";
configBaseline?: Record<string, string>;
/** Optional suffix appended to the trigger's model label (e.g. "(fallback)"). */
currentModelSuffix?: string;
/** Optional title tooltip on the trigger (e.g. explains a live-only note). */
triggerTitle?: string;
};
type ModelConfigSelectorTriggerProps = Pick<
ModelConfigSelectorProps,
"ariaLabel" | "disabled" | "placeholder" | "triggerClassName" | "triggerTitle" | "variant"
> & {
label: string;
details?: TriggerDetail[];
};
function ModelConfigSelectorTrigger({
ariaLabel,
details,
disabled,
label,
placeholder,
triggerClassName,
triggerTitle,
variant,
}: ModelConfigSelectorTriggerProps) {
const compact = variant === "compact";
const baseClassName = 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 trigger = (
<PopoverTrigger asChild>
<Button
type="button"
variant={compact ? "ghost" : "outline"}
size={compact ? "sm" : "default"}
className={cn(baseClassName, triggerClassName)}
aria-label={ariaLabel}
disabled={disabled}
title={triggerTitle}
>
<span className="truncate">{label || placeholder}</span>
<IconChevronDown className="h-3.5 w-3.5 shrink-0 opacity-70" />
</Button>
</PopoverTrigger>
);
if (!details?.length) return trigger;
return (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent>
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1">
{details.map((detail) => (
<div key={detail.id} className="contents">
<span className="font-medium">{detail.name}: </span>
<span className="min-w-0 break-words">{detail.value}</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
);
}
function triggerLabelOptions(
triggerSummary: "all" | "changed",
configBaseline: Record<string, string> | undefined,
currentModelSuffix?: string,
): TriggerLabelOptions | undefined {
if (triggerSummary !== "changed") return undefined;
return { summary: "changed", configBaseline, currentModelSuffix };
}
export const ModelConfigSelector = memo(function ModelConfigSelector({
modelOptions,
currentModel,
configOptions = [],
onModelChange,
onConfigChange,
disabled,
placeholder,
ariaLabel,
variant = "field",
popoverSide = "bottom",
triggerClassName: customTriggerClassName,
triggerSummary = "all",
configBaseline,
currentModelSuffix,
}: ModelConfigSelectorProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [activeConfigId, setActiveConfigId] = useState<string | null>(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,
triggerLabelOptions(triggerSummary, configBaseline, currentModelSuffix),
);
const details =
triggerSummary === "changed"
? triggerDetails(modelOptions, currentModel, modelConfig, extraConfigOptions, t)
: undefined;
// The (fallback) marker is a live WS signal, not replayed on reload. When
// it is active, explain on the trigger that the note is transient so users
// are not surprised when the marker disappears after a refresh.
const triggerTitle = currentModelSuffix ? t("settings:fallbackNoteLive") : undefined;
const hasExtraConfigOptions = extraConfigOptions.length > 0;
const onModelSelect = (value: string) => {
if (!value) return;
onModelChange(value);
if (!hasExtraConfigOptions) {
setOpen(false);
}
};
const onOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) {
setActiveConfigId(null);
}
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<ModelConfigSelectorTrigger
ariaLabel={ariaLabel ?? t("agents:modelSettings")}
details={details}
disabled={disabled}
label={label}
placeholder={placeholder ?? t("agents:selectModel")}
triggerClassName={customTriggerClassName}
triggerTitle={triggerTitle}
variant={variant}
/>
<PopoverContent
align="end"
side={popoverSide}
className="w-[min(24rem,calc(100vw-1rem))] max-h-[min(32rem,calc(100vh-1rem))] gap-2 overflow-hidden p-2"
>
<ModelConfigSelectorContent
activeConfig={activeConfig}
modelOptions={modelOptions}
currentModelValue={currentModelValue}
extraConfigOptions={extraConfigOptions}
onModelSelect={onModelSelect}
onConfigSelect={setActiveConfigId}
onConfigBack={() => setActiveConfigId(null)}
onConfigChange={onConfigChange}
/>
</PopoverContent>
</Popover>
);
});