From 39a5862d5447cbf13dee60411c2927aeb249dd20 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 4 Oct 2026 10:43:04 +0200
Subject: [PATCH] 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
---
e2e/ai-provider-dropdown.spec.ts | 112 ++++++++++++++++++
messages/de.json | 11 ++
messages/en.json | 11 ++
messages/es.json | 11 ++
messages/fr.json | 11 ++
messages/it.json | 11 ++
messages/ko.json | 11 ++
messages/pl.json | 11 ++
.../__tests__/reasoning-effort-field.test.tsx | 106 +++++++++++++++++
.../settings/ai/compat-provider-form.tsx | 17 ++-
.../settings/ai/local-provider-form.tsx | 13 +-
.../settings/ai/reasoning-effort-field.tsx | 52 ++++++++
src/components/settings/ai/shared.ts | 9 ++
13 files changed, 383 insertions(+), 3 deletions(-)
create mode 100644 src/components/settings/ai/__tests__/reasoning-effort-field.test.tsx
create mode 100644 src/components/settings/ai/reasoning-effort-field.tsx
diff --git a/e2e/ai-provider-dropdown.spec.ts b/e2e/ai-provider-dropdown.spec.ts
index ea2c00bb2..adacbdd2c 100644
--- a/e2e/ai-provider-dropdown.spec.ts
+++ b/e2e/ai-provider-dropdown.spec.ts
@@ -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> = [];
+ let providerState: Record = {
+ 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;
+ 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");
+ });
});
diff --git a/messages/de.json b/messages/de.json
index 0c7173452..5186bb8b6 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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": {
diff --git a/messages/en.json b/messages/en.json
index 3cd9aa9a4..fc41483a9 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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": {
diff --git a/messages/es.json b/messages/es.json
index d40131208..90919f624 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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": {
diff --git a/messages/fr.json b/messages/fr.json
index a5d0afa08..5d0c5570d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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": {
diff --git a/messages/it.json b/messages/it.json
index e0e489863..8654d8fea 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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": {
diff --git a/messages/ko.json b/messages/ko.json
index e51af7017..ac44f12e5 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -6080,6 +6080,17 @@
"title": "코치 계획",
"description": "코치와 합의한 계획을 읽거나 삭제할 수 있습니다.",
"open": "계획 열기"
+ },
+ "reasoning": {
+ "label": "추론",
+ "hint": "Gemma나 Qwen 같은 추론 모델은 답하기 전에 먼저 생각합니다. 끄면 답변 예산을 모두 답변 자체에 씁니다.",
+ "options": {
+ "default": "기본값",
+ "none": "끄기",
+ "low": "낮음",
+ "medium": "보통",
+ "high": "높음"
+ }
}
},
"dashboard": {
diff --git a/messages/pl.json b/messages/pl.json
index caa7f9245..17a2e68fe 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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": {
diff --git a/src/components/settings/ai/__tests__/reasoning-effort-field.test.tsx b/src/components/settings/ai/__tests__/reasoning-effort-field.test.tsx
new file mode 100644
index 000000000..0f2f2efd2
--- /dev/null
+++ b/src/components/settings/ai/__tests__/reasoning-effort-field.test.tsx
@@ -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 {
+ 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(
+
+ {node}
+ ,
+ );
+}
+
+/** The `
+
)}
+