From c95dcb34dbea565dbdd6d7153ba8bf45231fbd8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 6 Oct 2026 20:47:27 +0200 Subject: [PATCH] feat(settings): set how hard the Coach thinks, cap it as an operator, edit its memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thinking depth (Off, Low, Medium, High) sits under the model in the Coach's quick settings and in Settings → Coach. It renders the resolved block from the account payload: levels above the operator's cap are disabled and say so, the field locks with one sentence when the operator switched reasoning off, and Off reads Minimal where the provider cannot stop thinking. The provider's own reasoning select leaves the quick settings and says the Coach has its own. The operator gets a switch to allow deeper thinking and a select for the highest depth. The memory list shows where each entry came from, lets it be edited in place, and is linked from the quick settings with its count. The deep-linked data section no longer draws a ring. --- src/components/admin/assistant-section.tsx | 70 ++++- .../__tests__/coach-model-picker.test.tsx | 18 -- .../__tests__/coach-reasoning-field.test.tsx | 129 +++++++++ .../__tests__/coach-settings-overlay.test.tsx | 8 +- .../coach-panel/coach-model-picker.tsx | 42 +-- .../coach-panel/coach-reasoning-field.tsx | 196 +++++++++++++ .../coach-panel/coach-settings-body.tsx | 50 +++- .../coach-links-without-coach.test.tsx | 2 + .../__tests__/coach-memory-section.test.tsx | 50 +++- .../coach-prefs-sources-summary.test.tsx | 2 + .../settings/__tests__/coach-section.test.tsx | 2 + .../settings/ai/reasoning-effort-field.tsx | 10 + .../settings/coach-memory-section.tsx | 271 +++++++++++++----- .../settings/coach-prefs-section.tsx | 19 +- 14 files changed, 726 insertions(+), 143 deletions(-) create mode 100644 src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx create mode 100644 src/components/insights/coach-panel/coach-reasoning-field.tsx diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx index 11872bbb9..3ee132231 100644 --- a/src/components/admin/assistant-section.tsx +++ b/src/components/admin/assistant-section.tsx @@ -14,9 +14,23 @@ import { } from "@/lib/query-keys"; import { SettingsToggle } from "./_shared"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { + DEFAULT_REASONING_MAX_EFFORT, + REASONING_ADMIN_KEYS, + REASONING_LEVEL_LABEL_KEYS, + REASONING_MAX_EFFORTS, + type ReasoningMaxEffort, +} from "@/lib/ai/reasoning/levels"; /** - * Operator-side panel for the five assistant switches. The master stops + * Operator-side panel for the five assistant switches, and (v1.41) the two + * reasoning controls: whether models may think before they answer at all, + * and the highest depth a person may choose. Background jobs follow the + * same switch; there is no separate one. + * + * The five switches: The master stops * every AI feature; four sub-switches each stop one cost or egress profile: * the Coach, the daily briefing, status notes (per-reading notes, workout * notes, reaction lines) and reading documents (the vault, lab scans, @@ -48,8 +62,16 @@ interface AssistantFlagsResponse { insightStatus: boolean; documentAi: boolean; }; + /** v1.41 — the operator's reasoning controls; absent before v1.41. */ + reasoning?: { enabled: boolean; maxEffort: ReasoningMaxEffort }; } +/** What the route accepts: any of the switches, and the reasoning pair. */ +type AssistantFlagsPatch = Partial & { + aiReasoningEnabled?: boolean; + aiReasoningMaxEffort?: ReasoningMaxEffort; +}; + function useAssistantFlags() { return useQuery({ queryKey: queryKeys.adminAssistantFlags(), @@ -66,7 +88,7 @@ function useUpdateAssistantFlags() { const { t } = useTranslations(); return useMutation({ mutationFn: async ( - patch: Partial, + patch: AssistantFlagsPatch, ): Promise => { return apiPut( "/api/admin/settings/assistant-flags", @@ -102,6 +124,7 @@ export function AssistantSection() { const raw = data?.raw; const masterOn = raw?.assistantEnabled ?? true; const disabledSubs = !masterOn || mutation.isPending; + const reasoningOn = data?.reasoning?.enabled ?? true; return ( @@ -159,6 +182,49 @@ export function AssistantSection() { disabled={disabledSubs} /> + + {/* v1.41 — reasoning: allowed at all, and how deep at most. */} +
+ + mutation.mutate({ aiReasoningEnabled: checked }) + } + disabled={disabledSubs} + /> +
+ + + mutation.mutate({ + aiReasoningMaxEffort: e.target.value as ReasoningMaxEffort, + }) + } + > + {REASONING_MAX_EFFORTS.map((effort) => ( + + ))} + +
+
); diff --git a/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx index 2d4a8af6a..d24b0fca0 100644 --- a/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx @@ -8,7 +8,6 @@ import type { import { chainWithFirst, modelControlFor, - reasoningFieldFor, selectableProviders, switchDidNotTake, } from "../coach-model-picker"; @@ -120,20 +119,3 @@ describe("modelControlFor", () => { ).toEqual({ kind: "gateway", value: "qwen3:32b" }); }); }); - -describe("reasoningFieldFor", () => { - it("writes the Local and gateway entries' own reasoning fields", () => { - expect( - reasoningFieldFor("local", userProvider({ localReasoningEffort: "low" })), - ).toEqual({ field: "localReasoningEffort", value: "low" }); - expect(reasoningFieldFor("openai-compatible", userProvider())).toEqual({ - field: "compatReasoningEffort", - value: null, - }); - }); - - it("has nothing for providers without a reasoning setting", () => { - expect(reasoningFieldFor("openai", userProvider())).toBeNull(); - expect(reasoningFieldFor("codex", userProvider())).toBeNull(); - }); -}); diff --git a/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx b/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx new file mode 100644 index 000000000..25ec4c284 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx @@ -0,0 +1,129 @@ +/** + * v1.41 — the Coach's thinking depth: Off / Low / Medium / High, rendered + * from what the server resolved, never recomputed. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { getServerTranslator } from "@/lib/i18n/server-translator"; + +const locale = vi.hoisted(() => ({ current: "en" as "en" | "de" })); +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ + t: (key: string, params?: Record) => + getServerTranslator(locale.current).t(key, params), + }), +})); + +import { + CoachReasoningSelect, + reasoningLocked, + reasoningOptions, + reasoningStateOrDefault, + shownReasoningLevel, + type CoachReasoningState, +} from "../coach-reasoning-field"; + +const OPEN: CoachReasoningState = { + level: "medium", + preference: "medium", + maxLevel: "high", + available: true, + offIsReal: true, + source: "user", +}; +const ADMIN_OFF: CoachReasoningState = { + level: "off", + preference: "high", + maxLevel: "off", + available: false, + offIsReal: true, + source: "admin_off", +}; + +function render(state: CoachReasoningState, value = state.level) { + return renderToStaticMarkup( + {}} + />, + ); +} + +describe("the resolved block", () => { + it("reads an older server's missing block as allowed, uncapped, really off", () => { + expect(reasoningStateOrDefault(null)).toEqual(OPEN); + expect(reasoningStateOrDefault(undefined)).toEqual(OPEN); + }); + + it("is locked only when the operator switched reasoning off", () => { + expect(reasoningLocked(ADMIN_OFF)).toBe(true); + expect(reasoningLocked(OPEN)).toBe(false); + // A provider that cannot reason does not lock the person's choice. + expect( + reasoningLocked({ ...OPEN, available: false, source: "unsupported" }), + ).toBe(false); + }); +}); + +describe("", () => { + it("offers Off, Low, Medium and High with the label and hint", () => { + const html = render(OPEN); + expect(html).toContain(">Thinking depth<"); + for (const label of ["Off", "Low", "Medium", "High"]) { + expect(html).toContain(`>${label}`); + } + expect(html).toContain( + "Deeper thinking means more precise answers and a little more waiting.", + ); + expect(html).not.toMatch(/]*disabled/); + }); + + it("is locked with one sentence when the admin turned reasoning off", () => { + const html = render(ADMIN_OFF, "high"); + expect(html).toMatch(/]*disabled/); + expect(html).toContain('data-locked="true"'); + expect(html).toContain("The admin has turned deeper thinking off."); + expect(html).not.toContain("a little more waiting"); + }); + + it("shows the admin's cap: levels above it are disabled and say why", () => { + const html = render({ ...OPEN, maxLevel: "medium" }); + expect(html).toMatch( + /