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

+112
View File
@@ -339,4 +339,116 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => {
.locator('button[type="submit"]'),
).toBeEnabled();
});
// #1126 — the Reasoning select on the Local and OpenAI-compatible forms
// saves with the form, and at phone width it sits in the same column as
// the model field without pushing the page sideways.
test("reasoning setting saves from the Local and gateway forms at 390 px", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const patchBodies: Array<Record<string, unknown>> = [];
let providerState: Record<string, unknown> = {
provider: "LOCAL",
model: null,
baseUrl: "http://localhost:11434/v1",
responseTimeoutSeconds: null,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
compatBaseUrl: "https://gateway.example.org/v1",
compatModel: "qwen3",
hasCompatKey: false,
localReasoningEffort: null,
compatReasoningEffort: null,
};
await page.route("**/api/insights/settings", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
codexStatus: "disconnected",
codexConnectedAt: null,
hasAdminKey: false,
codexOauthConfigured: true,
privacyMode: "aggregated",
lastInsightAt: null,
},
error: null,
}),
}),
);
await page.route("**/api/user/ai-provider", async (route) => {
if (route.request().method() === "PATCH") {
const body = route.request().postDataJSON() as Record<string, unknown>;
patchBodies.push(body);
providerState = { ...providerState, ...body };
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: providerState, error: null }),
});
});
await page.route("**/api/insights/provider-chain", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
activeProvider: "local",
cachedActiveProvider: null,
configuredChain: [
{ providerType: "local", enabled: true, available: true },
],
},
error: null,
}),
}),
);
const providerHydrated = page.waitForResponse("**/api/user/ai-provider");
await page.goto("/settings/ai?provider=local", {
waitUntil: "domcontentloaded",
});
await providerHydrated;
const localForm = page.getByTestId("ai-provider-config-local");
const localReasoning = page.locator("#ai-local-reasoning");
await expect(localReasoning).toHaveValue("");
// Same column, same width as the model select above it.
const [modelBox, reasoningBox] = await Promise.all([
page.locator("#ai-local-model").boundingBox(),
localReasoning.boundingBox(),
]);
expect(reasoningBox?.x).toBe(modelBox?.x);
expect(reasoningBox?.width).toBe(modelBox?.width);
expect(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
document.documentElement.clientWidth,
),
).toBe(true);
await localReasoning.selectOption("none");
await localForm.locator('button[type="submit"]').click();
await expect.poll(() => patchBodies.length).toBe(1);
expect(patchBodies[0].localReasoningEffort).toBe("none");
await page
.getByTestId("ai-active-provider-select")
.selectOption("openai-compatible");
const compatForm = page.getByTestId("ai-provider-config-openai-compatible");
const compatReasoning = page.locator("#ai-compat-reasoning");
await expect(compatReasoning).toHaveValue("");
await compatReasoning.selectOption("high");
await compatForm.locator('button[type="submit"]').click();
await expect.poll(() => patchBodies.length).toBe(2);
expect(patchBodies[1].compatReasoningEffort).toBe("high");
expect(patchBodies[1]).not.toHaveProperty("localReasoningEffort");
});
});
+11
View File
@@ -6080,6 +6080,17 @@
"title": "Coach-Pläne",
"description": "Die Pläne, die du mit dem Coach vereinbart hast, zum Lesen oder Entfernen.",
"open": "Pläne öffnen"
},
"reasoning": {
"label": "Nachdenken",
"hint": "Reasoning-Modelle wie Gemma oder Qwen denken nach, bevor sie antworten. Schaltest du es aus, bleibt das ganze Antwortbudget für die Antwort selbst.",
"options": {
"default": "Standard",
"none": "Aus",
"low": "Niedrig",
"medium": "Mittel",
"high": "Hoch"
}
}
},
"dashboard": {
+11
View File
@@ -6080,6 +6080,17 @@
"title": "Coach plans",
"description": "The plans you agreed with the Coach, to read or remove.",
"open": "Open plans"
},
"reasoning": {
"label": "Reasoning",
"hint": "Reasoning models such as Gemma or Qwen think before they answer. Off leaves the whole answer budget for the answer itself.",
"options": {
"default": "Default",
"none": "Off",
"low": "Low",
"medium": "Medium",
"high": "High"
}
}
},
"dashboard": {
+11
View File
@@ -6080,6 +6080,17 @@
"title": "Planes del Coach",
"description": "Los planes que acordaste con el Coach, para leerlos o eliminarlos.",
"open": "Abrir planes"
},
"reasoning": {
"label": "Razonamiento",
"hint": "Los modelos de razonamiento como Gemma o Qwen piensan antes de responder. Si lo desactivas, todo el presupuesto de respuesta se queda para la respuesta.",
"options": {
"default": "Predeterminado",
"none": "Desactivado",
"low": "Bajo",
"medium": "Medio",
"high": "Alto"
}
}
},
"dashboard": {
+11
View File
@@ -6080,6 +6080,17 @@
"title": "Plans du Coach",
"description": "Les plans convenus avec le Coach, à lire ou à supprimer.",
"open": "Ouvrir les plans"
},
"reasoning": {
"label": "Raisonnement",
"hint": "Les modèles de raisonnement comme Gemma ou Qwen réfléchissent avant de répondre. Désactivé, tout le budget de réponse sert à la réponse elle-même.",
"options": {
"default": "Par défaut",
"none": "Désactivé",
"low": "Faible",
"medium": "Moyen",
"high": "Élevé"
}
}
},
"dashboard": {
+11
View File
@@ -6080,6 +6080,17 @@
"title": "Piani del Coach",
"description": "I piani concordati con il Coach, da leggere o rimuovere.",
"open": "Apri i piani"
},
"reasoning": {
"label": "Ragionamento",
"hint": "I modelli di ragionamento come Gemma o Qwen pensano prima di rispondere. Se lo disattivi, tutto il budget della risposta resta per la risposta stessa.",
"options": {
"default": "Predefinito",
"none": "Disattivato",
"low": "Basso",
"medium": "Medio",
"high": "Alto"
}
}
},
"dashboard": {
+11
View File
@@ -6080,6 +6080,17 @@
"title": "코치 계획",
"description": "코치와 합의한 계획을 읽거나 삭제할 수 있습니다.",
"open": "계획 열기"
},
"reasoning": {
"label": "추론",
"hint": "Gemma나 Qwen 같은 추론 모델은 답하기 전에 먼저 생각합니다. 끄면 답변 예산을 모두 답변 자체에 씁니다.",
"options": {
"default": "기본값",
"none": "끄기",
"low": "낮음",
"medium": "보통",
"high": "높음"
}
}
},
"dashboard": {
+11
View File
@@ -6080,6 +6080,17 @@
"title": "Plany z Coachem",
"description": "Plany uzgodnione z Coachem, do przeczytania lub usunięcia.",
"open": "Otwórz plany"
},
"reasoning": {
"label": "Rozumowanie",
"hint": "Modele rozumujące, takie jak Gemma czy Qwen, myślą przed odpowiedzią. Po wyłączeniu cały budżet odpowiedzi idzie na samą odpowiedź.",
"options": {
"default": "Domyślnie",
"none": "Wyłączone",
"low": "Niskie",
"medium": "Średnie",
"high": "Wysokie"
}
}
},
"dashboard": {
@@ -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