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({