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. */}
+
{t("insights.coach.frame.saving")}
diff --git a/src/components/insights/coach-panel/coach-reasoning-field.tsx b/src/components/insights/coach-panel/coach-reasoning-field.tsx
new file mode 100644
index 000000000..8ea3a5038
--- /dev/null
+++ b/src/components/insights/coach-panel/coach-reasoning-field.tsx
@@ -0,0 +1,196 @@
+"use client";
+
+/**
+ * v1.41 — how hard the Coach thinks before it answers: one select, Off / Low
+ * / Medium / High, Medium by default.
+ *
+ * The person's own choice lives in `coachPrefsJson.reasoning`. What the
+ * server resolved from it rides `/api/auth/me` as `coachReasoning`: whether
+ * the operator allows reasoning at all, the highest level they allow, and
+ * whether "off" is really off on the active provider. The field renders from
+ * that and never recomputes it:
+ * - the operator switched reasoning off: the select is locked and one
+ * sentence says so;
+ * - a level above the operator's cap is offered but disabled, with
+ * "(limited by the admin)";
+ * - where the provider cannot switch thinking off, "Off" reads "Minimal".
+ * A server older than v1.41 sends no block; the field then reads as allowed,
+ * uncapped and really off.
+ *
+ * Two homes, one field: the Coach's quick settings (saves on change) and
+ * Settings → Coach (part of that card's draft and save).
+ */
+import { useQueryClient } from "@tanstack/react-query";
+
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import { useTranslations } from "@/lib/i18n/context";
+import { useCoachReasoning } from "@/hooks/use-ai-capability";
+import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
+import { queryKeys } from "@/lib/query-keys";
+import {
+ DEFAULT_REASONING_LEVEL,
+ REASONING_LEVELS,
+ REASONING_LEVEL_LABEL_KEYS,
+ REASONING_SETTING_KEYS,
+ isReasoningLevel,
+ type ReasoningLevel,
+} from "@/lib/ai/reasoning/levels";
+import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
+import { coachReasoningLevel } from "@/lib/validations/coach-prefs";
+
+export type { CoachReasoningState };
+
+/**
+ * The block as the field reads it: the server's answer, or for a server that
+ * does not publish one (older than v1.41) "allowed, uncapped, really off" at
+ * the default level.
+ */
+export function reasoningStateOrDefault(
+ state: CoachReasoningState | null | undefined,
+): CoachReasoningState {
+ return (
+ state ?? {
+ level: DEFAULT_REASONING_LEVEL,
+ preference: DEFAULT_REASONING_LEVEL,
+ maxLevel: "high",
+ available: true,
+ offIsReal: true,
+ source: "user",
+ }
+ );
+}
+
+/** True when the operator switched reasoning off: the field is locked. */
+export function reasoningLocked(state: CoachReasoningState): boolean {
+ return state.maxLevel === "off" || state.source === "admin_off";
+}
+
+export interface ReasoningOption {
+ value: ReasoningLevel;
+ label: string;
+ disabled: boolean;
+}
+
+/** The four options in order, labelled and capped for this account. */
+export function reasoningOptions(
+ state: CoachReasoningState,
+ t: (key: string) => string,
+): ReasoningOption[] {
+ const locked = reasoningLocked(state);
+ const cap = REASONING_LEVELS.indexOf(state.maxLevel);
+ return REASONING_LEVELS.map((value, index) => {
+ const base = t(
+ value === "off" && !state.offIsReal
+ ? REASONING_LEVEL_LABEL_KEYS.minimal
+ : REASONING_LEVEL_LABEL_KEYS[value],
+ );
+ // Above the cap is "(limited by the admin)"; while reasoning is off the
+ // one sentence under the field says it, not every option.
+ const capped = !locked && index > cap;
+ return {
+ value,
+ label: capped ? `${base} ${t(REASONING_SETTING_KEYS.capped)}` : base,
+ disabled: capped,
+ };
+ });
+}
+
+/**
+ * The level the select shows: the person's choice, lowered to the cap when
+ * it is above it (that is what runs), and the resolved level while the
+ * operator has reasoning off.
+ */
+export function shownReasoningLevel(
+ choice: ReasoningLevel,
+ state: CoachReasoningState,
+): ReasoningLevel {
+ if (reasoningLocked(state)) return state.level;
+ return REASONING_LEVELS.indexOf(choice) >
+ REASONING_LEVELS.indexOf(state.maxLevel)
+ ? state.maxLevel
+ : choice;
+}
+
+export interface CoachReasoningSelectProps {
+ id: string;
+ value: ReasoningLevel;
+ state: CoachReasoningState;
+ /** Locks the select while a write is in flight. */
+ busy?: boolean;
+ onChange: (next: ReasoningLevel) => void;
+}
+
+/** The field itself, without data: what the tests render. */
+export function CoachReasoningSelect({
+ id,
+ value,
+ state,
+ busy = false,
+ onChange,
+}: CoachReasoningSelectProps) {
+ const { t } = useTranslations();
+ const options = reasoningOptions(state, t);
+ const locked = reasoningLocked(state);
+ return (
+
+
+ {
+ const next = e.target.value;
+ if (isReasoningLevel(next) && next !== value) onChange(next);
+ }}
+ >
+ {options.map((option) => (
+
+ ))}
+
+
+ );
+}
+
+/** The quick-settings field: reads the saved choice and saves on change. */
+export function CoachReasoningField({ id }: { id: string }) {
+ const queryClient = useQueryClient();
+ const reasoning = useCoachReasoning();
+ const prefs = useCoachPrefs();
+ const save = useSaveCoachPrefs({
+ // The resolved block on `/me` follows the saved choice.
+ onSuccess: () =>
+ void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }),
+ });
+ if (!prefs.data) return null;
+ const current = prefs.data;
+ const state = reasoningStateOrDefault(reasoning);
+ return (
+ save.mutate({ ...current, reasoning })}
+ />
+ );
+}
diff --git a/src/components/insights/coach-panel/coach-settings-body.tsx b/src/components/insights/coach-panel/coach-settings-body.tsx
index 2a844cbb9..31b8da580 100644
--- a/src/components/insights/coach-panel/coach-settings-body.tsx
+++ b/src/components/insights/coach-panel/coach-settings-body.tsx
@@ -2,27 +2,63 @@
import { useEffect, useRef } from "react";
import Link from "next/link";
-import { Cpu } from "lucide-react";
+import { Brain, ChevronRight, Cpu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
+import { COACH_MEMORY_LIST_KEYS } from "@/lib/ai/coach/memory/shared";
+
import { CoachModelPicker } from "./coach-model-picker";
+import { CoachReasoningField } from "./coach-reasoning-field";
+import { useCoachFacts } from "./use-coach-facts";
import { SourcesRail } from "./sources-rail";
/**
* The content of the Coach's quick settings (see `coach-settings-overlay`):
- * the model choice, "What I can see" (the existing sources rail, wired to the
- * saved Coach preferences: data areas and how far back the Coach looks) and
- * a way on to the full AI settings.
+ * the model choice and how hard the Coach thinks, a way to what it remembers,
+ * "What I can see" (the existing sources rail, wired to the saved Coach
+ * preferences: data areas and how far back the Coach looks) and a way on to
+ * the full AI settings.
*
* `focusData` lands focus on the "What I can see" section when the body
* mounts, for the `/coach?settings=data` deep link. It runs here rather than
* in the overlay because the body loads on demand and may arrive after the
- * popover or sheet has already placed focus.
+ * popover or sheet has already placed focus. The section draws no focus
+ * ring: it is where the link lands, not a control, and a ring drawn on every
+ * deep link reads as a highlight nobody asked for.
*/
export const COACH_SETTINGS_DATA_SECTION_ID = "coach-settings-data";
+/** Where the memory list lives: Settings → Coach, its memory card. */
+export const COACH_MEMORY_HREF = "/settings/coach#coach-memory";
+
+/** "Memory (8)": a link to the list, with how much it holds. */
+function CoachMemoryLink() {
+ const { t } = useTranslations();
+ const facts = useCoachFacts();
+ const count = facts.data?.length ?? 0;
+ return (
+
+
+
+ {t(COACH_MEMORY_LIST_KEYS.link, { count })}
+
+
+
+ );
+}
+
export function CoachSettingsBody({
focusData = false,
}: {
@@ -53,6 +89,8 @@ export function CoachSettingsBody({
{t("insights.coach.frame.modelSection")}
+
+
diff --git a/src/components/settings/__tests__/coach-links-without-coach.test.tsx b/src/components/settings/__tests__/coach-links-without-coach.test.tsx
index 4a2ecb8da..5025a769a 100644
--- a/src/components/settings/__tests__/coach-links-without-coach.test.tsx
+++ b/src/components/settings/__tests__/coach-links-without-coach.test.tsx
@@ -15,6 +15,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
reason: coachState.available ? null : "no_provider",
onDeviceAllowed: false,
}),
+ // v1.41 — the thinking depth reads the resolved block; none here.
+ useCoachReasoning: () => null,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
diff --git a/src/components/settings/__tests__/coach-memory-section.test.tsx b/src/components/settings/__tests__/coach-memory-section.test.tsx
index c5ca68404..d93d4b891 100644
--- a/src/components/settings/__tests__/coach-memory-section.test.tsx
+++ b/src/components/settings/__tests__/coach-memory-section.test.tsx
@@ -25,10 +25,17 @@ import { CoachMemorySection } from "../coach-memory-section";
interface SeedFact {
id: string;
- category: "preference" | "condition" | "goal" | "constraint" | "context";
+ category:
+ | "preference"
+ | "condition"
+ | "goal"
+ | "constraint"
+ | "context"
+ | "medication";
text: string;
confidence: number;
createdAt: string;
+ source?: "user" | "coach" | "extracted" | "pattern";
}
const NOW = "2026-06-04T12:00:00.000Z";
@@ -152,4 +159,45 @@ describe("Settings — CoachMemorySection", () => {
expect(withFacts).toContain("rolling summary");
expect(empty).toContain("rolling summary");
});
+
+ it("says where each fact came from, and offers to edit it", () => {
+ const html = render([
+ { ...buildFacts()[0], source: "user" },
+ { ...buildFacts()[1], source: "coach" },
+ { ...buildFacts()[2], source: "extracted" },
+ {
+ id: "fact-med-1",
+ category: "medication",
+ text: "Takes ramipril in the morning",
+ confidence: 1,
+ createdAt: NOW,
+ source: "user",
+ },
+ ]);
+ expect(
+ html.match(/data-slot="settings-coach-memory-source"/g),
+ ).toHaveLength(4);
+ expect(html).toContain("from you");
+ expect(html).toContain("from the Coach");
+ expect(html.match(/data-slot="settings-coach-memory-edit"/g)).toHaveLength(
+ 4,
+ );
+ expect(html).toMatch(
+ /data-slot="settings-coach-memory-edit"[^>]*aria-label="Edit"/,
+ );
+ // Medications get their own group.
+ expect(html).toContain(
+ 'data-testid="settings-coach-memory-group-medication"',
+ );
+ expect(html).toContain("Medications");
+ // The quick settings land here.
+ expect(html).toMatch(/]*id="coach-memory"/);
+ });
+
+ it("lists goals first", () => {
+ const html = render(buildFacts());
+ expect(html.indexOf("group-goal")).toBeLessThan(
+ html.indexOf("group-preference"),
+ );
+ });
});
diff --git a/src/components/settings/__tests__/coach-prefs-sources-summary.test.tsx b/src/components/settings/__tests__/coach-prefs-sources-summary.test.tsx
index a1d4158a6..23d8317c5 100644
--- a/src/components/settings/__tests__/coach-prefs-sources-summary.test.tsx
+++ b/src/components/settings/__tests__/coach-prefs-sources-summary.test.tsx
@@ -13,6 +13,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
reason: null,
onDeviceAllowed: false,
}),
+ // v1.41 — the thinking depth reads the resolved block; none here.
+ useCoachReasoning: () => null,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx
index 20c9689d2..157b489e1 100644
--- a/src/components/settings/__tests__/coach-section.test.tsx
+++ b/src/components/settings/__tests__/coach-section.test.tsx
@@ -36,6 +36,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
reason: null,
onDeviceAllowed: true,
}),
+ // v1.41 — the thinking depth reads the resolved block; none here.
+ useCoachReasoning: () => null,
}));
const authState: {
diff --git a/src/components/settings/ai/reasoning-effort-field.tsx b/src/components/settings/ai/reasoning-effort-field.tsx
index fee0f4e3f..c6698b641 100644
--- a/src/components/settings/ai/reasoning-effort-field.tsx
+++ b/src/components/settings/ai/reasoning-effort-field.tsx
@@ -4,6 +4,8 @@
* 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.
+ * v1.41 — the Coach reads its own thinking depth from the Coach settings;
+ * a line under the hint says so.
* ──────────────────────────────────────────────────────────────── */
import { Label } from "@/components/ui/label";
@@ -56,6 +58,14 @@ export function ReasoningEffortField({