mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(settings): set how hard the Coach thinks, cap it as an operator, edit its memory
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.
This commit is contained in:
1 parent
11bafd68b5
commit
c95dcb34db
14 files changed
+726
-143
No files matched your search
@@ -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<AssistantFlagsResponse["raw"]> & {
|
||||
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<AssistantFlagsResponse["raw"]>,
|
||||
patch: AssistantFlagsPatch,
|
||||
): Promise<AssistantFlagsResponse> => {
|
||||
return apiPut<AssistantFlagsResponse>(
|
||||
"/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 (
|
||||
<SettingsCard>
|
||||
@@ -159,6 +182,49 @@ export function AssistantSection() {
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.41 — reasoning: allowed at all, and how deep at most. */}
|
||||
<div
|
||||
data-slot="admin-assistant-reasoning"
|
||||
className="border-border space-y-4 border-t pt-4"
|
||||
>
|
||||
<SettingsToggle
|
||||
label={t(REASONING_ADMIN_KEYS.enabled)}
|
||||
description={t("admin.assistant.reasoning.description")}
|
||||
checked={reasoningOn}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ aiReasoningEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<Label
|
||||
htmlFor="admin-reasoning-max"
|
||||
noColon
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t(REASONING_ADMIN_KEYS.max)}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="admin-reasoning-max"
|
||||
data-slot="admin-reasoning-max"
|
||||
className="sm:w-48"
|
||||
value={data?.reasoning?.maxEffort ?? DEFAULT_REASONING_MAX_EFFORT}
|
||||
disabled={disabledSubs || !reasoningOn}
|
||||
onChange={(e) =>
|
||||
mutation.mutate({
|
||||
aiReasoningMaxEffort: e.target.value as ReasoningMaxEffort,
|
||||
})
|
||||
}
|
||||
>
|
||||
{REASONING_MAX_EFFORTS.map((effort) => (
|
||||
<option key={effort} value={effort}>
|
||||
{t(REASONING_LEVEL_LABEL_KEYS[effort])}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | number>) =>
|
||||
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(
|
||||
<CoachReasoningSelect
|
||||
id="r"
|
||||
value={value}
|
||||
state={state}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
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("<CoachReasoningSelect>", () => {
|
||||
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}</option>`);
|
||||
}
|
||||
expect(html).toContain(
|
||||
"Deeper thinking means more precise answers and a little more waiting.",
|
||||
);
|
||||
expect(html).not.toMatch(/<select[^>]*disabled/);
|
||||
});
|
||||
|
||||
it("is locked with one sentence when the admin turned reasoning off", () => {
|
||||
const html = render(ADMIN_OFF, "high");
|
||||
expect(html).toMatch(/<select[^>]*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(
|
||||
/<option value="high" disabled="">High \(limited by the admin\)<\/option>/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<option value="medium"( selected="")?>Medium<\/option>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("says Minimal where the provider cannot switch thinking off", () => {
|
||||
const options = reasoningOptions(
|
||||
{ ...OPEN, offIsReal: false },
|
||||
getServerTranslator("en").t,
|
||||
);
|
||||
expect(options[0]).toEqual({
|
||||
value: "off",
|
||||
label: "Minimal",
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a choice above the cap at the cap, which is what runs", () => {
|
||||
expect(shownReasoningLevel("high", { ...OPEN, maxLevel: "low" })).toBe(
|
||||
"low",
|
||||
);
|
||||
expect(shownReasoningLevel("low", OPEN)).toBe("low");
|
||||
expect(shownReasoningLevel("high", ADMIN_OFF)).toBe("off");
|
||||
});
|
||||
|
||||
it("reads in German", () => {
|
||||
locale.current = "de";
|
||||
const html = render({ ...OPEN, maxLevel: "low" });
|
||||
locale.current = "en";
|
||||
expect(html).toContain(">Denktiefe<");
|
||||
expect(html).toContain("Hoch (vom Admin begrenzt)");
|
||||
});
|
||||
});
|
||||
@@ -109,13 +109,13 @@ describe("<CoachSettingsBody>", () => {
|
||||
|
||||
it("locks the reasoning select while a change is saving", async () => {
|
||||
// Two writes in flight at once could land out of order and lose one;
|
||||
// the provider and model selects already lock, the reasoning one too.
|
||||
// the provider and model selects already lock, the thinking depth too.
|
||||
const source = (await import("node:fs")).readFileSync(
|
||||
new URL("../coach-model-picker.tsx", import.meta.url),
|
||||
new URL("../coach-reasoning-field.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const field = source.match(/<ReasoningEffortField[\s\S]*?\/>/)?.[0];
|
||||
expect(field).toMatch(/disabled=\{busy\}/);
|
||||
expect(source).toMatch(/disabled=\{locked \|\| busy\}/);
|
||||
expect(source).toMatch(/busy=\{save\.isPending\}/);
|
||||
});
|
||||
|
||||
it("names the data section so a deep link can land on it", () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { ReasoningEffortField } from "@/components/settings/ai/reasoning-effort-field";
|
||||
import {
|
||||
ANTHROPIC_MODEL_PRESETS,
|
||||
LOCAL_MODEL_PRESETS,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
uiToLegacyProviderEnum,
|
||||
type ProviderChainData,
|
||||
type ProviderType,
|
||||
type ReasoningEffort,
|
||||
type UserAIProvider,
|
||||
} from "@/components/settings/ai/shared";
|
||||
import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch";
|
||||
@@ -29,7 +27,9 @@ import {
|
||||
|
||||
/**
|
||||
* The quick model choice in the Coach's settings overlay: which provider
|
||||
* answers, which model it uses, and how hard a reasoning model thinks.
|
||||
* answers and which model it uses. How hard the Coach thinks is the Coach's
|
||||
* own setting since v1.41 (`coach-reasoning-field.tsx`); the provider's
|
||||
* reasoning select stays in Settings → AI for everything else.
|
||||
*
|
||||
* It is a shortcut onto the same settings Settings → AI writes, never a second
|
||||
* copy of them. Keys, base URLs, the Codex sign-in and the order of the
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
* - Provider: `PUT /api/insights/provider-chain` with the chosen entry moved
|
||||
* to the front and every other entry in its current order. The entries'
|
||||
* reasoning settings are not sent, so the server keeps what it stored.
|
||||
* - Model and reasoning: `PATCH /api/user/ai-provider`, only where a person
|
||||
* - Model: `PATCH /api/user/ai-provider`, only where a person
|
||||
* owns the setting (their own key's provider, or their gateway). The
|
||||
* operator's shared provider and Codex show their state read-only.
|
||||
* Every write invalidates the AI input keys, so the capability on
|
||||
@@ -127,29 +127,6 @@ export function modelControlFor(
|
||||
return { kind: "managed" };
|
||||
}
|
||||
|
||||
/** The reasoning setting the active provider carries, if any. */
|
||||
export function reasoningFieldFor(
|
||||
active: ProviderType | null,
|
||||
userProvider: UserAIProvider | null | undefined,
|
||||
): {
|
||||
field: "localReasoningEffort" | "compatReasoningEffort";
|
||||
value: ReasoningEffort | null;
|
||||
} | null {
|
||||
if (active === "local") {
|
||||
return {
|
||||
field: "localReasoningEffort",
|
||||
value: userProvider?.localReasoningEffort ?? null,
|
||||
};
|
||||
}
|
||||
if (active === "openai-compatible") {
|
||||
return {
|
||||
field: "compatReasoningEffort",
|
||||
value: userProvider?.compatReasoningEffort ?? null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CoachModelPicker() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -212,7 +189,6 @@ export function CoachModelPicker() {
|
||||
const active = chain?.activeProvider ?? null;
|
||||
const options = chain ? selectableProviders(chain.configuredChain) : [];
|
||||
const model = modelControlFor(active, userProvider);
|
||||
const reasoning = reasoningFieldFor(active, userProvider);
|
||||
const busy = switchProvider.isPending || patchProvider.isPending;
|
||||
// The server may answer with `admin-codex`, which the UI's type omits.
|
||||
const unlisted = activeOutsideOptions(active as string | null, options);
|
||||
@@ -339,16 +315,6 @@ export function CoachModelPicker() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{reasoning ? (
|
||||
<ReasoningEffortField
|
||||
id="coach-quick-reasoning"
|
||||
noColon
|
||||
value={reasoning.value}
|
||||
disabled={busy}
|
||||
onChange={(next) => patchProvider.mutate({ [reasoning.field]: next })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{busy ? (
|
||||
<p role="status" className="text-muted-foreground text-xs">
|
||||
{t("insights.coach.frame.saving")}
|
||||
|
||||
@@ -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 (
|
||||
<div data-slot="coach-reasoning-field">
|
||||
<Label htmlFor={id} noColon>
|
||||
{t(REASONING_SETTING_KEYS.label)}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id={id}
|
||||
data-slot="coach-reasoning-select"
|
||||
data-locked={locked ? "true" : undefined}
|
||||
className="mt-1"
|
||||
value={shownReasoningLevel(value, state)}
|
||||
disabled={locked || busy}
|
||||
aria-describedby={`${id}-hint`}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
if (isReasoningLevel(next) && next !== value) onChange(next);
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p id={`${id}-hint`} className="text-muted-foreground mt-1 text-xs">
|
||||
{t(
|
||||
locked
|
||||
? REASONING_SETTING_KEYS.disabled
|
||||
: REASONING_SETTING_KEYS.hint,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<CoachReasoningSelect
|
||||
id={id}
|
||||
value={coachReasoningLevel(current)}
|
||||
state={state}
|
||||
busy={save.isPending}
|
||||
onChange={(reasoning) => save.mutate({ ...current, reasoning })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Link
|
||||
href={COACH_MEMORY_HREF}
|
||||
data-slot="coach-settings-memory-link"
|
||||
className="text-foreground hover:bg-muted focus-visible:ring-ring/50 -mx-2 flex min-h-11 items-center gap-2 rounded-md px-2 text-sm outline-none focus-visible:ring-2 sm:min-h-9"
|
||||
>
|
||||
<Brain
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
{t(COACH_MEMORY_LIST_KEYS.link, { count })}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function CoachSettingsBody({
|
||||
focusData = false,
|
||||
}: {
|
||||
@@ -53,6 +89,8 @@ export function CoachSettingsBody({
|
||||
{t("insights.coach.frame.modelSection")}
|
||||
</h3>
|
||||
<CoachModelPicker />
|
||||
<CoachReasoningField id="coach-quick-reasoning" />
|
||||
<CoachMemoryLink />
|
||||
</section>
|
||||
<section
|
||||
ref={dataRef}
|
||||
@@ -60,7 +98,7 @@ export function CoachSettingsBody({
|
||||
data-slot="coach-settings-data"
|
||||
tabIndex={-1}
|
||||
aria-label={t("insights.coach.sourcesTitle")}
|
||||
className="border-border focus-visible:ring-ring/50 border-t pt-4 outline-none focus-visible:ring-2"
|
||||
className="border-border border-t pt-4 outline-none"
|
||||
>
|
||||
<SourcesRail className="h-auto p-0" />
|
||||
</section>
|
||||
|
||||
@@ -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() }),
|
||||
|
||||
@@ -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(/<section[^>]*id="coach-memory"/);
|
||||
});
|
||||
|
||||
it("lists goals first", () => {
|
||||
const html = render(buildFacts());
|
||||
expect(html.indexOf("group-goal")).toBeLessThan(
|
||||
html.indexOf("group-preference"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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() }),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
<p id={`${id}-hint`} className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.ai.reasoning.hint")}
|
||||
</p>
|
||||
{/* v1.41 — the Coach has its own thinking depth; this select is the
|
||||
default for everything else the provider does. */}
|
||||
<p
|
||||
data-slot="reasoning-effort-coach-note"
|
||||
className="text-muted-foreground mt-1 text-xs"
|
||||
>
|
||||
{t("settings.ai.reasoning.coachNote")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,12 @@
|
||||
* every read/write routes its key through `queryKeys.coachFacts()` so
|
||||
* a forget invalidates the list.
|
||||
*
|
||||
* v1.41 — the Coach's one memory. Each entry says where it came from
|
||||
* ("from you" for what the person asked it to keep, "from the Coach" for what
|
||||
* it kept or worked out itself), and can be edited in place
|
||||
* (`PATCH /api/insights/coach/facts/{id}`) as well as forgotten. The Coach's
|
||||
* quick settings link here (`#coach-memory`).
|
||||
*
|
||||
* v1.39 — never gated on the Coach. What the Coach stored is the person's
|
||||
* own record: it stays readable and deletable while the Coach is off for any
|
||||
* reason (the operator's switch, Hide Coach, no provider, no consent). The
|
||||
@@ -24,9 +30,9 @@
|
||||
* shows this card; while that page is not reachable, Settings → AI shows it
|
||||
* through `<StoredCoachMemory>` below, whenever rows exist.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Brain, Loader2, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Brain, Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -50,42 +56,192 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "sonner";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { apiDelete } from "@/lib/api/api-fetch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
COACH_MEMORY_LIST_KEYS,
|
||||
REMEMBER_FACT_MAX_CHARS,
|
||||
} from "@/lib/ai/coach/memory/shared";
|
||||
import {
|
||||
useCoachFacts,
|
||||
useEditCoachFact,
|
||||
useForgetCoachFact,
|
||||
type CoachFactDTO,
|
||||
} from "@/components/insights/coach-panel/use-coach-facts";
|
||||
import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card";
|
||||
import { CoachRemindersSection } from "@/components/settings/coach-reminders-section";
|
||||
import { CoachPlansMemoryCard } from "@/components/settings/coach-plans-memory-card";
|
||||
|
||||
/** Closed enum mirrored from the server `CoachFact.category` column. */
|
||||
/**
|
||||
* The categories in the order the list shows them: goals first, then how the
|
||||
* person likes things, then health, then the rest.
|
||||
*/
|
||||
const FACT_CATEGORIES = [
|
||||
"goal",
|
||||
"preference",
|
||||
"condition",
|
||||
"goal",
|
||||
"medication",
|
||||
"constraint",
|
||||
"context",
|
||||
] as const;
|
||||
type FactCategory = (typeof FACT_CATEGORIES)[number];
|
||||
|
||||
interface CoachFact {
|
||||
id: string;
|
||||
category: FactCategory;
|
||||
text: string;
|
||||
confidence: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const CATEGORY_LABEL_KEY: Record<FactCategory, string> = {
|
||||
goal: "settings.ai.coachMemory.categoryGoal",
|
||||
preference: "settings.ai.coachMemory.categoryPreference",
|
||||
condition: "settings.ai.coachMemory.categoryCondition",
|
||||
goal: "settings.ai.coachMemory.categoryGoal",
|
||||
medication: COACH_MEMORY_LIST_KEYS.categoryMedication,
|
||||
constraint: "settings.ai.coachMemory.categoryConstraint",
|
||||
context: "settings.ai.coachMemory.categoryContext",
|
||||
};
|
||||
|
||||
async function fetchFacts(): Promise<CoachFact[]> {
|
||||
const data = await apiGet<{ facts?: CoachFact[] } | undefined>(
|
||||
"/api/insights/coach/facts",
|
||||
/** "from you" for what the person asked to keep; everything else the Coach. */
|
||||
export function factSourceKey(
|
||||
fact: Pick<CoachFactDTO, "source">,
|
||||
): string | null {
|
||||
if (!fact.source) return null;
|
||||
return fact.source === "user"
|
||||
? COACH_MEMORY_LIST_KEYS.sourceUser
|
||||
: COACH_MEMORY_LIST_KEYS.sourceCoach;
|
||||
}
|
||||
|
||||
/** One entry, read or being edited. */
|
||||
function FactRow({
|
||||
fact,
|
||||
isAuthenticated,
|
||||
forgetting,
|
||||
onForget,
|
||||
}: {
|
||||
fact: CoachFactDTO;
|
||||
isAuthenticated: boolean;
|
||||
forgetting: boolean;
|
||||
onForget: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(fact.text);
|
||||
const edit = useEditCoachFact({
|
||||
// The edited words in place are the confirmation.
|
||||
onSuccess: () => setEditing(false),
|
||||
onError: () => toast.error(t("admin.settingsSaveError")),
|
||||
});
|
||||
const sourceKey = factSourceKey(fact);
|
||||
const trimmed = draft.trim();
|
||||
const inputId = `coach-memory-edit-${fact.id}`;
|
||||
return (
|
||||
<li
|
||||
data-testid="settings-coach-memory-fact"
|
||||
data-source={fact.source}
|
||||
className="border-border bg-background flex flex-col gap-2 rounded-lg border p-3"
|
||||
>
|
||||
{editing ? (
|
||||
<form
|
||||
className="flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (trimmed && trimmed !== fact.text) {
|
||||
edit.mutate({ id: fact.id, fact: trimmed });
|
||||
} else {
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label htmlFor={inputId} className="sr-only">
|
||||
{t(COACH_MEMORY_LIST_KEYS.edit)}
|
||||
</label>
|
||||
<Textarea
|
||||
id={inputId}
|
||||
data-slot="settings-coach-memory-edit-input"
|
||||
value={draft}
|
||||
maxLength={REMEMBER_FACT_MAX_CHARS}
|
||||
rows={2}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setDraft(fact.text);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
{t("settings.ai.coachMemory.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="settings-coach-memory-edit-save"
|
||||
disabled={!trimmed || edit.isPending}
|
||||
>
|
||||
{edit.isPending ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{t("settings.ai.saveCta")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm break-words">{fact.text}</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.ai.coachMemory.learnedPrefix", {
|
||||
when: formatDateOrRelative(fact.createdAt, t),
|
||||
})}
|
||||
{sourceKey ? (
|
||||
<span data-slot="settings-coach-memory-source">
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span className="sr-only">, </span>
|
||||
{t(sourceKey)}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="settings-coach-memory-edit"
|
||||
aria-label={t(COACH_MEMORY_LIST_KEYS.edit)}
|
||||
title={t(COACH_MEMORY_LIST_KEYS.edit)}
|
||||
disabled={!isAuthenticated}
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => {
|
||||
setDraft(fact.text);
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<ConfirmButton
|
||||
slot="settings-coach-memory-forget"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
ariaLabel={t("settings.ai.coachMemory.forgetAria")}
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
label={t("settings.ai.coachMemory.forget")}
|
||||
title={t("settings.ai.coachMemory.forgetTitle")}
|
||||
body={t("settings.ai.coachMemory.forgetBody")}
|
||||
confirmLabel={t("settings.ai.coachMemory.forgetConfirm")}
|
||||
disabled={!isAuthenticated}
|
||||
pending={forgetting}
|
||||
onConfirm={onForget}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
return data?.facts ?? [];
|
||||
}
|
||||
|
||||
export function CoachMemorySection({
|
||||
@@ -99,25 +255,11 @@ export function CoachMemorySection({
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.coachFacts(),
|
||||
queryFn: fetchFacts,
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
const query = useCoachFacts({ enabled: isAuthenticated });
|
||||
|
||||
const forgetOne = useMutation({
|
||||
mutationKey: queryKeys.coachFacts(),
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/insights/coach/facts/${encodeURIComponent(id)}`);
|
||||
return id;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("settings.ai.coachMemory.forgotToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("settings.ai.coachMemory.forgotError"));
|
||||
},
|
||||
const forgetOne = useForgetCoachFact({
|
||||
onSuccess: () => toast.success(t("settings.ai.coachMemory.forgotToast")),
|
||||
onError: () => toast.error(t("settings.ai.coachMemory.forgotError")),
|
||||
});
|
||||
|
||||
const forgetAll = useMutation({
|
||||
@@ -144,9 +286,16 @@ export function CoachMemorySection({
|
||||
// Group by category in the canonical category order so the panel
|
||||
// reads the same way every render regardless of insertion order.
|
||||
const grouped = useMemo(() => {
|
||||
// A category the list does not know (an older extraction's) reads as
|
||||
// context rather than vanishing.
|
||||
const known = new Set<string>(FACT_CATEGORIES);
|
||||
return FACT_CATEGORIES.map((category) => ({
|
||||
category,
|
||||
items: facts.filter((f) => f.category === category),
|
||||
items: facts.filter((f) =>
|
||||
known.has(f.category)
|
||||
? f.category === category
|
||||
: category === "context",
|
||||
),
|
||||
})).filter((g) => g.items.length > 0);
|
||||
}, [facts]);
|
||||
|
||||
@@ -156,6 +305,8 @@ export function CoachMemorySection({
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
id="coach-memory"
|
||||
className="scroll-mt-28"
|
||||
aria-labelledby="settings-ai-coach-memory-title"
|
||||
data-testid="settings-coach-memory-card"
|
||||
>
|
||||
@@ -193,42 +344,16 @@ export function CoachMemorySection({
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{group.items.map((fact) => (
|
||||
<li
|
||||
<FactRow
|
||||
key={fact.id}
|
||||
data-testid="settings-coach-memory-fact"
|
||||
className="border-border bg-background flex items-start justify-between gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm break-words">{fact.text}</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.ai.coachMemory.learnedPrefix", {
|
||||
when: formatDateOrRelative(fact.createdAt, t),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<ConfirmButton
|
||||
slot="settings-coach-memory-forget"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
ariaLabel={t("settings.ai.coachMemory.forgetAria")}
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
label={t("settings.ai.coachMemory.forget")}
|
||||
title={t("settings.ai.coachMemory.forgetTitle")}
|
||||
body={t("settings.ai.coachMemory.forgetBody")}
|
||||
confirmLabel={t("settings.ai.coachMemory.forgetConfirm")}
|
||||
disabled={!isAuthenticated}
|
||||
// v1.11.2 — per-id pending: only the row being
|
||||
// deleted disables/spins. `forgetOne.variables` holds
|
||||
// the id passed to the in-flight `mutate()`, so a
|
||||
// single shared mutation no longer greys out every
|
||||
// other row's forget button during one delete.
|
||||
pending={
|
||||
forgetOne.isPending && forgetOne.variables === fact.id
|
||||
}
|
||||
onConfirm={() => forgetOne.mutate(fact.id)}
|
||||
/>
|
||||
</li>
|
||||
fact={fact}
|
||||
isAuthenticated={isAuthenticated}
|
||||
// Per-id pending: only the row being deleted spins.
|
||||
forgetting={
|
||||
forgetOne.isPending && forgetOne.variables === fact.id
|
||||
}
|
||||
onForget={() => forgetOne.mutate(fact.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -39,13 +39,18 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import {
|
||||
CoachReasoningSelect,
|
||||
reasoningStateOrDefault,
|
||||
} from "@/components/insights/coach-panel/coach-reasoning-field";
|
||||
import { useAiCapability, useCoachReasoning } from "@/hooks/use-ai-capability";
|
||||
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import { HISTORY_REACH_DAYS, lookbackText } from "@/lib/ai/coach/history-reach";
|
||||
import {
|
||||
DEFAULT_COACH_CLUSTERS,
|
||||
DEFAULT_COACH_PREFS,
|
||||
coachDataClusterEnum,
|
||||
coachReasoningLevel,
|
||||
type CoachExcludeMetric,
|
||||
type CoachPrefs,
|
||||
type CoachTone,
|
||||
@@ -106,6 +111,7 @@ export interface CoachPrefsSectionProps {
|
||||
export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const coach = useAiCapability("coach");
|
||||
const reasoningState = reasoningStateOrDefault(useCoachReasoning());
|
||||
|
||||
const { data: persisted } = useCoachPrefs({ enabled: isAuthenticated });
|
||||
|
||||
@@ -252,6 +258,17 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v1.41 — how hard the Coach thinks; the same field sits in the
|
||||
Coach's quick settings. */}
|
||||
<CoachReasoningSelect
|
||||
id="coach-prefs-reasoning"
|
||||
value={coachReasoningLevel(draft)}
|
||||
state={reasoningState}
|
||||
onChange={(reasoning) =>
|
||||
setDraft((prev) => ({ ...prev, reasoning }))
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Data clusters + analysis window live on ONE owner: "What I
|
||||
can see" behind the Coach's settings gear, which
|
||||
`/coach?settings=data` opens directly. This card used to render
|
||||
|
||||
Reference in new issue
Block a user