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 `", start)); +} + +const OPTION_VALUES = ["", "none", "low", "medium", "high"]; + +describe.each([ + { + name: "Local", + id: "ai-local-reasoning", + after: "ai-local-model", + form: (p: UserAIProvider) => , + stored: (v: UserAIProvider["localReasoningEffort"]) => ({ + localReasoningEffort: v, + }), + }, + { + name: "OpenAI-compatible", + id: "ai-compat-reasoning", + after: "ai-compat-model", + form: (p: UserAIProvider) => , + 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(/`); + } + expect(html).toContain(` { + const select = selectMarkup(render(form(provider({}))), id); + expect(select).toMatch(/