feat(settings): add a Reasoning select to the Local and gateway forms

One select under the model field of the Local and OpenAI-compatible
provider forms: Default, Off, Low, Medium, High, with a one-line hint
that Off leaves the answer budget for the answer itself. It reuses the
NativeSelect, label and hint layout of the field above it, seeds from
the stored entry, and saves with the form. Strings in all seven
locales.

Refs #1126
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 10:43:04 +02:00
1 parent befac59576
commit 39a5862d54
13 files changed
+383 -3

No files matched your search

@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import en from "../../../../../messages/en.json";
import { CompatProviderForm } from "../compat-provider-form";
import { LocalProviderForm } from "../local-provider-form";
import type { UserAIProvider } from "../shared";
/**
* #1126 — the Reasoning select on the Local and OpenAI-compatible forms. It
* sits right under the model field, offers exactly Default / Off / Low /
* Medium / High, and seeds from the value the server stored on the entry.
* Saving it is pinned end to end in `e2e/ai-provider-dropdown.spec.ts`.
*/
function provider(overrides: Partial<UserAIProvider>): UserAIProvider {
return {
provider: "LOCAL",
model: "llama3:8b",
baseUrl: "http://ollama.example.org:11434/v1",
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
compatBaseUrl: "https://gateway.example.org/v1",
compatModel: "qwen3",
hasCompatKey: false,
responseTimeoutSeconds: null,
...overrides,
};
}
function render(node: React.ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
/** The `<select>` element with the given id, as markup. */
function selectMarkup(html: string, id: string): string {
const match = new RegExp(`<select[^>]*\\bid="${id}"`).exec(html);
expect(match, `no select #${id}`).not.toBeNull();
const start = match!.index;
return html.slice(start, html.indexOf("</select>", start));
}
const OPTION_VALUES = ["", "none", "low", "medium", "high"];
describe.each([
{
name: "Local",
id: "ai-local-reasoning",
after: "ai-local-model",
form: (p: UserAIProvider) => <LocalProviderForm userProvider={p} />,
stored: (v: UserAIProvider["localReasoningEffort"]) => ({
localReasoningEffort: v,
}),
},
{
name: "OpenAI-compatible",
id: "ai-compat-reasoning",
after: "ai-compat-model",
form: (p: UserAIProvider) => <CompatProviderForm userProvider={p} />,
stored: (v: UserAIProvider["compatReasoningEffort"]) => ({
compatReasoningEffort: v,
provider: "OPENAI_COMPATIBLE",
}),
},
])("$name form", ({ id, after, form, stored }) => {
it("offers Default, Off, Low, Medium and High under the model field", () => {
const html = render(form(provider({})));
const select = selectMarkup(html, id);
expect(
[...select.matchAll(/<option value="([^"]*)"/g)].map((m) => m[1]),
).toEqual(OPTION_VALUES);
const r = en.settings.ai.reasoning;
for (const label of Object.values(r.options)) {
expect(select).toContain(`>${label}</option>`);
}
expect(html).toContain(`<label`);
expect(html).toContain(`for="${id}"`);
expect(html).toContain(r.label);
expect(html).toContain(r.hint);
expect(html.indexOf(`id="${id}"`)).toBeGreaterThan(
html.indexOf(`id="${after}"`),
);
});
it("seeds Default when nothing is stored, or a payload predates the field", () => {
const select = selectMarkup(render(form(provider({}))), id);
expect(select).toMatch(/<option value="" selected="">/);
});
it("seeds the stored value", () => {
const select = selectMarkup(render(form(provider(stored("none")))), id);
expect(select).toMatch(/<option value="none" selected="">/);
});
});
@@ -19,7 +19,12 @@ import { apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
import { uiToLegacyProviderEnum, type UserAIProvider } from "./shared";
import { ReasoningEffortField } from "./reasoning-effort-field";
import {
uiToLegacyProviderEnum,
type ReasoningEffort,
type UserAIProvider,
} from "./shared";
export function CompatProviderForm({
userProvider,
@@ -32,6 +37,7 @@ export function CompatProviderForm({
const [baseUrl, setBaseUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState("");
const [reasoning, setReasoning] = useState<ReasoningEffort | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const submitInFlightRef = useRef(false);
@@ -40,13 +46,14 @@ export function CompatProviderForm({
// render-time pattern the sibling forms use (no setState-in-effect).
const seededKey =
userProvider != null
? `${userProvider.compatBaseUrl ?? ""}|${userProvider.compatModel ?? ""}`
? `${userProvider.compatBaseUrl ?? ""}|${userProvider.compatModel ?? ""}|${userProvider.compatReasoningEffort ?? ""}`
: null;
const [previousSeed, setPreviousSeed] = useState<string | null>(null);
if (seededKey && seededKey !== previousSeed) {
setPreviousSeed(seededKey);
setBaseUrl(userProvider?.compatBaseUrl ?? "");
setModel(userProvider?.compatModel ?? "");
setReasoning(userProvider?.compatReasoningEffort ?? null);
}
const saveMutation = useMutation({
@@ -55,6 +62,7 @@ export function CompatProviderForm({
provider: uiToLegacyProviderEnum("openai-compatible"),
compatBaseUrl: baseUrl.trim() || null,
compatModel: model.trim() || null,
compatReasoningEffort: reasoning,
};
if (apiKey.trim()) body.compatKey = apiKey.trim();
await apiPatch("/api/user/ai-provider", body);
@@ -130,6 +138,11 @@ export function CompatProviderForm({
{t("settings.ai.compat.modelHint")}
</p>
</div>
<ReasoningEffortField
id="ai-compat-reasoning"
value={reasoning}
onChange={setReasoning}
/>
<div>
<Button
@@ -17,10 +17,12 @@ import { apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
import { ReasoningEffortField } from "./reasoning-effort-field";
import {
CUSTOM_MODEL_SENTINEL,
LOCAL_MODEL_PRESETS,
uiToLegacyProviderEnum,
type ReasoningEffort,
type UserAIProvider,
} from "./shared";
@@ -36,17 +38,20 @@ export function LocalProviderForm({
const [apiKey, setApiKey] = useState("");
const [modelChoice, setModelChoice] = useState<string>("");
const [customModel, setCustomModel] = useState("");
const [reasoning, setReasoning] = useState<ReasoningEffort | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const submitInFlightRef = useRef(false);
const seededKey =
userProvider != null
? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}|${userProvider.baseUrl ?? ""}`
? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}|${userProvider.baseUrl ?? ""}|${userProvider.localReasoningEffort ?? ""}`
: null;
const [previousSeed, setPreviousSeed] = useState<string | null>(null);
if (seededKey && seededKey !== previousSeed) {
setPreviousSeed(seededKey);
// Stored on the chain entry, so it seeds whichever provider is selected.
setReasoning(userProvider?.localReasoningEffort ?? null);
if (userProvider?.provider === "LOCAL") {
setBaseUrl(userProvider.baseUrl ?? "");
const saved = userProvider.model ?? "";
@@ -71,6 +76,7 @@ export function LocalProviderForm({
provider: uiToLegacyProviderEnum("local"),
baseUrl: baseUrl.trim() || null,
model: effectiveModel || null,
localReasoningEffort: reasoning,
};
if (apiKey.trim()) body.localKey = apiKey.trim();
await apiPatch("/api/user/ai-provider", body);
@@ -168,6 +174,11 @@ export function LocalProviderForm({
/>
</div>
)}
<ReasoningEffortField
id="ai-local-reasoning"
value={reasoning}
onChange={setReasoning}
/>
<div>
<Button
@@ -0,0 +1,52 @@
"use client";
/* ────────────────────────────────────────────────────────────────
* Reasoning select (#1126) for the Local and OpenAI-compatible forms.
* Same Label + NativeSelect + hint layout as the model field above it.
* The empty value is Default: nothing is sent and the model decides.
* ──────────────────────────────────────────────────────────────── */
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useTranslations } from "@/lib/i18n/context";
import type { ReasoningEffort } from "./shared";
export function ReasoningEffortField({
id,
value,
onChange,
}: {
id: string;
value: ReasoningEffort | null;
onChange: (next: ReasoningEffort | null) => void;
}) {
const { t } = useTranslations();
return (
<div>
<Label htmlFor={id}>{t("settings.ai.reasoning.label")}</Label>
<NativeSelect
id={id}
value={value ?? ""}
onChange={(e) =>
onChange(
e.target.value === "" ? null : (e.target.value as ReasoningEffort),
)
}
className="mt-1"
aria-describedby={`${id}-hint`}
>
<option value="">{t("settings.ai.reasoning.options.default")}</option>
<option value="none">{t("settings.ai.reasoning.options.none")}</option>
<option value="low">{t("settings.ai.reasoning.options.low")}</option>
<option value="medium">
{t("settings.ai.reasoning.options.medium")}
</option>
<option value="high">{t("settings.ai.reasoning.options.high")}</option>
</NativeSelect>
<p id={`${id}-hint`} className="text-muted-foreground mt-1 text-xs">
{t("settings.ai.reasoning.hint")}
</p>
</div>
);
}
+9
View File
@@ -5,6 +5,8 @@
* this module under `src/components/settings/ai/`.
*/
import type { ReasoningEffort } from "@/lib/ai/reasoning-effort";
export interface InsightsSettings {
codexStatus: string;
codexConnectedAt: string | null;
@@ -40,8 +42,15 @@ export interface UserAIProvider {
hasCompatKey: boolean;
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
responseTimeoutSeconds: number | null;
// #1126 — reasoning setting of the Local and gateway entries (null =
// Default). Absent on a payload from a server older than the field.
localReasoningEffort?: ReasoningEffort | null;
compatReasoningEffort?: ReasoningEffort | null;
}
/** #1126 — the `reasoning_effort` values; "Off" is `none`. */
export type { ReasoningEffort };
/**
* v1.4.16 phase B2 — provider tags exposed to the UI. Mirrors
* `PROVIDER_CHAIN_TYPES` server-side; kept as a const-array here to