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( + /