mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Match model and thinking effort selects in agent setup
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
970bd7e8dc
commit
cd1e43ab0e
4 files changed
+78
-14
No files matched your search
@@ -44,7 +44,8 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { selectTriggerClassName } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, selectTriggerClassName } from "@/components/ui/select";
|
||||
import { AdapterMark } from "./AdapterMark";
|
||||
import { FolderOpen, Heart, ChevronDown, X, Copy, Check, ExternalLink, Loader2, TriangleAlert, Bug } from "lucide-react";
|
||||
import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils";
|
||||
@@ -3755,6 +3756,7 @@ export function ModelDropdown({
|
||||
detectModelLabel,
|
||||
emptyDetectHint,
|
||||
defaultLabel,
|
||||
presentation = "searchable",
|
||||
}: {
|
||||
models: AdapterModel[];
|
||||
value: string;
|
||||
@@ -3776,8 +3778,10 @@ export function ModelDropdown({
|
||||
detectModelLabel?: string;
|
||||
emptyDetectHint?: string;
|
||||
defaultLabel?: string;
|
||||
presentation?: "searchable" | "native";
|
||||
}) {
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
const [enteringCustomModel, setEnteringCustomModel] = useState(false);
|
||||
const [detectingModel, setDetectingModel] = useState(false);
|
||||
const selected = models.find((m) => m.id === value);
|
||||
const manualModel = modelSearch.trim();
|
||||
@@ -3846,6 +3850,47 @@ export function ModelDropdown({
|
||||
}
|
||||
}
|
||||
|
||||
if (presentation === "native") {
|
||||
const customOption = "__paperclip_custom_model__";
|
||||
const extraModels = [...new Set([value, ...promotedModelIds])].filter(id => id && (!models.some(model => model.id === id) || promotedModelIds.has(id)));
|
||||
return (
|
||||
<Field label="Model" hint={help.model}>
|
||||
<NativeSelect
|
||||
aria-label="Model"
|
||||
aria-busy={loadingModels || refreshingModels}
|
||||
value={enteringCustomModel ? customOption : value}
|
||||
required={required && !enteringCustomModel}
|
||||
onChange={event => {
|
||||
const next = event.target.value;
|
||||
setEnteringCustomModel(next === customOption);
|
||||
if (next !== customOption) onChange(next);
|
||||
}}
|
||||
>
|
||||
<option value="" disabled={!allowDefault}>
|
||||
{allowDefault ? (defaultLabel ?? "Default") : loadingModels ? "Loading models…" : required ? "Select model (required)" : "Select model"}
|
||||
</option>
|
||||
{extraModels.map(id => <option key={id} value={id}>{models.find(model => model.id === id)?.label ?? id}</option>)}
|
||||
{groupedModels.map(({ provider, entries }) => groupByProvider ? (
|
||||
<optgroup key={provider} label={provider}>
|
||||
{entries.map(model => <option key={model.id} value={model.id}>{model.label}</option>)}
|
||||
</optgroup>
|
||||
) : entries.map(model => <option key={model.id} value={model.id}>{model.label}</option>))}
|
||||
{creatable && <option value={customOption}>Enter custom model…</option>}
|
||||
</NativeSelect>
|
||||
{enteringCustomModel && (
|
||||
<label className="mt-3 block space-y-1 text-xs text-muted-foreground">
|
||||
Model ID
|
||||
<Input aria-label="Model ID" value={value} onChange={event => onChange(event.target.value)}
|
||||
placeholder="Enter model ID or alias" autoFocus required={required} />
|
||||
</label>
|
||||
)}
|
||||
{loadingModels && <p role="status" className="mt-2 text-xs text-muted-foreground">Loading models…</p>}
|
||||
{onRefreshModels && <Button type="button" variant="ghost" size="sm" disabled={refreshingModels}
|
||||
onClick={() => void onRefreshModels()}>{refreshingModels ? "Refreshing…" : "Refresh models"}</Button>}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Field label="Model" hint={help.model}>
|
||||
<Popover
|
||||
|
||||
@@ -54,6 +54,7 @@ import { Field } from "../agent-config-primitives";
|
||||
import { SecretPicker } from "../environment-variables-editor/SecretPicker";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { NativeSelect, nativeSelectClassName } from "../ui/select";
|
||||
import {
|
||||
OnboardingCard,
|
||||
OnboardingHeading,
|
||||
@@ -67,8 +68,7 @@ import {
|
||||
} from "./AgentProviderConnection";
|
||||
import { adapterCuratesModelOrder } from "../../lib/model-utils";
|
||||
|
||||
const controlClass =
|
||||
"w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm leading-5 outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
||||
const controlClass = nativeSelectClassName;
|
||||
const blocking = (result: AdapterEnvironmentTestResult) =>
|
||||
result.status === "fail" ||
|
||||
result.checks.some((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE);
|
||||
@@ -844,6 +844,7 @@ function Setup({
|
||||
<div className="grid items-start gap-5 sm:grid-cols-2">
|
||||
{showModel && !usingKimiApi && (
|
||||
<ModelDropdown
|
||||
presentation="native"
|
||||
models={connectionModels?.models ?? models.data ?? []}
|
||||
loadingModels={connectionModels?.isLoading ?? models.isLoading}
|
||||
onRefreshModels={connectionModels?.refreshModels}
|
||||
@@ -881,9 +882,8 @@ function Setup({
|
||||
)}
|
||||
{efforts.length > 0 && (
|
||||
<Field label="Thinking effort">
|
||||
<select
|
||||
<NativeSelect
|
||||
aria-label="Thinking effort"
|
||||
className={controlClass}
|
||||
value={effort}
|
||||
onChange={(event) => {
|
||||
setEffort(event.target.value);
|
||||
@@ -896,7 +896,7 @@ function Setup({
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,12 @@ import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export const nativeSelectClassName = "w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm leading-5 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
|
||||
export function NativeSelect({ className, ...props }: React.ComponentProps<"select">) {
|
||||
return <select {...props} className={cn(nativeSelectClassName, className)} />
|
||||
}
|
||||
|
||||
// Shared with searchable picker triggers so their size and states match Select.
|
||||
export const selectTriggerClassName = "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm whitespace-nowrap shadow-xs transition-(--tp-color-box-shadow) outline-none focus-visible:ring-(length:--rad-3) disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { AiConnectionField } from "@/components/ai-connections/AiConnectionField
|
||||
import { AgentProviderConnection, type ProviderConnection } from "@/components/new-agent/AgentProviderConnection";
|
||||
import { useConnectionModels } from "@/components/ai-connections/useConnectionModels";
|
||||
import { ModelDropdown } from "@/components/AgentConfigForm";
|
||||
import { Field } from "@/components/agent-config-primitives";
|
||||
import { NativeSelect } from "@/components/ui/select";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import type {
|
||||
AiConnectionBinding,
|
||||
@@ -107,13 +109,19 @@ function Connection() {
|
||||
function OpenRouterModelPicker() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const [effort, setEffort] = useState("");
|
||||
const connection = useConnectionModels(companyId, {
|
||||
provider: "openrouter", method: "api_key", mode: "shared",
|
||||
connectionId: accounts[1].id, grantId: accounts[1].grantId,
|
||||
}, "opencode_local");
|
||||
return <ModelDropdown models={connection?.models ?? []} value={value} onChange={setValue}
|
||||
return <div className="grid gap-5 sm:grid-cols-2">
|
||||
<ModelDropdown presentation="native" models={connection?.models ?? []} value={value} onChange={setValue}
|
||||
open={open} onOpenChange={setOpen} allowDefault={false} required groupByProvider={false}
|
||||
preserveOrder creatable loadingModels={connection?.isLoading} />;
|
||||
preserveOrder creatable loadingModels={connection?.isLoading} />
|
||||
<Field label="Thinking effort"><NativeSelect aria-label="Thinking effort" value={effort} onChange={event => setEffort(event.target.value)}>
|
||||
<option value="">Auto</option><option value="high">high</option>
|
||||
</NativeSelect></Field>
|
||||
</div>;
|
||||
}
|
||||
function OpenRouterModels() {
|
||||
const [client] = useState(() => {
|
||||
@@ -192,13 +200,18 @@ export const OpenRouterPopularModels: Story = {
|
||||
render: () => <OpenRouterModels />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Select model (required)" }));
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const labels = Array.from(canvasElement.ownerDocument.querySelectorAll('span[title^="openrouter/"]')).map(element => element.textContent);
|
||||
const model = canvas.getByRole("combobox", { name: "Model" });
|
||||
const labels = within(model).getAllByRole("option").filter(option => (option as HTMLOptionElement).value.startsWith("openrouter/")).map(option => option.textContent);
|
||||
await expect(labels).toEqual(["Z.AI GLM 5", "Claude Sonnet 4.5", "OpenAI GPT-5.4"]);
|
||||
await userEvent.type(body.getByPlaceholderText("Search models... (type to create)"), "claude");
|
||||
await userEvent.click(body.getByRole("button", { name: "Claude Sonnet 4.5" }));
|
||||
await expect(canvas.getByRole("button", { name: "Claude Sonnet 4.5" })).toBeVisible();
|
||||
await userEvent.selectOptions(model, "openrouter/anthropic/claude-sonnet-4.5");
|
||||
await expect(model).toHaveValue("openrouter/anthropic/claude-sonnet-4.5");
|
||||
await userEvent.selectOptions(canvas.getByRole("combobox", { name: "Thinking effort" }), "high");
|
||||
await userEvent.selectOptions(model, within(model).getByRole("option", { name: "Enter custom model…" }));
|
||||
await userEvent.clear(canvas.getByRole("textbox", { name: "Model ID" }));
|
||||
await userEvent.type(canvas.getByRole("textbox", { name: "Model ID" }), "my-gateway/model-alias");
|
||||
await expect(canvas.getByRole("textbox", { name: "Model ID" })).toHaveValue("my-gateway/model-alias");
|
||||
await userEvent.selectOptions(model, "openrouter/anthropic/claude-sonnet-4.5");
|
||||
await expect(canvas.queryByRole("textbox", { name: "Model ID" })).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const ChooseProvider: Story = {
|
||||
|
||||
Reference in new issue
Block a user