mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(settings): explain operator switches and keep the Coach memory reachable
Settings, AI says which AI features the server's operator switched off and shows the one setup hint while no provider is configured. The consent withdrawal says what it deletes and what stays, and a document-reading consent on file gets its own withdrawal. Regenerate follows the briefing capability and the connection test names an operator refusal. The Coach memory (facts and stored conversations) is never gated on the Coach: Settings, Coach always shows it, and Settings, AI shows it while the Coach page is out of reach, whenever rows exist. Onboarding offers AI only where it can be set up here: the done-screen panel and the getting-started row go away while the operator has AI off or inside somebody else's record.
This commit is contained in:
1 parent
4449b34ac7
commit
3d85445e50
24 files changed
+913
-232
No files matched your search
@@ -72,10 +72,9 @@ function useUpdateAssistantFlags() {
|
||||
onSuccess: (data) => {
|
||||
client.setQueryData(queryKeys.adminAssistantFlags(), data);
|
||||
// Every record's resolved AI capabilities ride the account payload,
|
||||
// and the deprecated switch projection is still read on the web;
|
||||
// bust both so the operator sees the change within the session.
|
||||
// which is the only thing the web reads them from; bust it so the
|
||||
// operator sees the change within the session.
|
||||
client.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
client.invalidateQueries({ queryKey: queryKeys.featureFlags() });
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
@@ -66,6 +66,19 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: { id: "u1" } }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* `ai.provider.canConfigure` from `/api/auth/me`: the panel is one optional
|
||||
* offer, shown only while AI can be set up from here.
|
||||
*/
|
||||
const providerState = vi.hoisted(() => ({ canConfigure: true }));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiProviderState: () => ({
|
||||
configured: false,
|
||||
managedBy: null,
|
||||
canConfigure: providerState.canConfigure,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({ mutate: () => {}, isPending: false }),
|
||||
}));
|
||||
@@ -129,6 +142,18 @@ describe("<DoneScreen> AI panel", () => {
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
});
|
||||
|
||||
it("leaves the panel out when AI cannot be set up here", () => {
|
||||
// The operator switched AI off, or the record belongs to somebody else.
|
||||
// HealthLog is complete without AI, so the screen goes from "done"
|
||||
// straight to the exits.
|
||||
providerState.canConfigure = false;
|
||||
aiProviderState.data = undefined;
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-panel"');
|
||||
expect(html).toContain('data-slot="onboarding-open-dashboard"');
|
||||
providerState.canConfigure = true;
|
||||
});
|
||||
|
||||
it("keeps every exit so the panel is fully skippable", () => {
|
||||
aiProviderState.data = undefined;
|
||||
const html = render();
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
type OnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs";
|
||||
|
||||
import type { AiProviderState } from "@/lib/ai/capabilities/types";
|
||||
|
||||
let currentUser: AuthUser | null = null;
|
||||
/** Every `enabled` flag the component asked for, keyed by its query key. */
|
||||
const enabledByKey = new Map<string, boolean>();
|
||||
@@ -32,6 +34,16 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
useAccountOnceMounted: () => currentUser,
|
||||
}));
|
||||
|
||||
/** `ai.provider` from `/api/auth/me`, read through `useAiProviderState`. */
|
||||
let providerState: AiProviderState = {
|
||||
configured: true,
|
||||
managedBy: "user",
|
||||
canConfigure: true,
|
||||
};
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiProviderState: () => providerState,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/queries/use-dashboard-snapshot", () => ({
|
||||
useDashboardSnapshot: () => ({
|
||||
data: { tiles: { summaries: { weight: { count: 12 } } } },
|
||||
@@ -40,7 +52,6 @@ vi.mock("@/lib/queries/use-dashboard-snapshot", () => ({
|
||||
|
||||
const RESPONSES: Record<string, unknown> = {
|
||||
'["medications"]': [{ id: "m1" }, { id: "m2" }, { id: "m3" }],
|
||||
'["user","ai-provider"]': { aiAvailable: true },
|
||||
'["integrations","status"]': {
|
||||
integrations: [{ integration: "whoop", connected: true }],
|
||||
},
|
||||
@@ -121,13 +132,24 @@ describe("<GettingStartedChecklist> — rows for an established account", () =>
|
||||
expect(html).toContain("5 of 6 done");
|
||||
});
|
||||
|
||||
it("leaves the AI row out when AI cannot be set up here", () => {
|
||||
// The operator switched AI off, or the record belongs to somebody else:
|
||||
// the list never carries a to-do nobody on this screen can finish.
|
||||
providerState = { configured: false, managedBy: null, canConfigure: false };
|
||||
currentUser = user();
|
||||
const html = render();
|
||||
expect(html).toContain("4 of 5 done");
|
||||
providerState = { configured: true, managedBy: "user", canConfigure: true };
|
||||
});
|
||||
|
||||
it("keeps the supporting queries enabled while the card can render", () => {
|
||||
currentUser = user();
|
||||
render();
|
||||
expect(enabledByKey.get('["medications"]')).toBe(true);
|
||||
expect(enabledByKey.get('["integrations","status"]')).toBe(true);
|
||||
expect(enabledByKey.get('["notifications","preferences"]')).toBe(true);
|
||||
expect(enabledByKey.get('["user","ai-provider"]')).toBe(true);
|
||||
// The AI row reads `/api/auth/me`; it asks for nothing of its own.
|
||||
expect(enabledByKey.has('["user","ai-provider"]')).toBe(false);
|
||||
});
|
||||
|
||||
it("still fetches nothing for a record that never entered the flow and is long past five readings", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAiProviderState } from "@/hooks/use-ai-capability";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -127,12 +128,19 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
// The server decides now, and the four branches below only READ it. The
|
||||
// rest of the panel — sample, ladder, the "fully useful without AI" line,
|
||||
// the setup link — is identical in every branch; nothing here is a gate.
|
||||
// The panel is one optional offer, shown only while this person could set
|
||||
// AI up here: `provider.canConfigure` is false while the operator has AI
|
||||
// switched off and inside a record somebody else owns. HealthLog is
|
||||
// complete without it, so with nothing to offer the screen simply goes
|
||||
// from "done" to the exits.
|
||||
const providerState = useAiProviderState();
|
||||
const showAiPanel = providerState.canConfigure;
|
||||
const { data: aiProvider } = useQuery<AiProviderStatus>({
|
||||
queryKey: queryKeys.userAiProvider(),
|
||||
queryFn: async () => {
|
||||
return apiGet("/api/user/ai-provider");
|
||||
},
|
||||
enabled: !!user,
|
||||
enabled: !!user && showAiPanel,
|
||||
});
|
||||
|
||||
// The tap's own outcome, held locally so the line changes the moment the
|
||||
@@ -232,163 +240,165 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
honest local-first ladder and the "useful without AI" release
|
||||
valve. Value-first, never a gate: the three exits below stay,
|
||||
and setup is a single optional deep-link. */}
|
||||
<section
|
||||
aria-labelledby="onboarding-ai-panel-title"
|
||||
data-slot="onboarding-ai-panel"
|
||||
/* Which of the four variants the panel SETTLED on, and absent until
|
||||
{showAiPanel ? (
|
||||
<section
|
||||
aria-labelledby="onboarding-ai-panel-title"
|
||||
data-slot="onboarding-ai-panel"
|
||||
/* Which of the four variants the panel SETTLED on, and absent until
|
||||
it has settled. The panel itself is unconditional and paints
|
||||
immediately, so without this an assertion that a variant is absent
|
||||
passes on a status that has simply not arrived yet. The value is
|
||||
the same tri-state decision made once, above. */
|
||||
data-ai-state={
|
||||
aiProvider === undefined
|
||||
? undefined
|
||||
: sharedKeyNote
|
||||
? "consent"
|
||||
: offerShared
|
||||
? "offer"
|
||||
: sharedUnavailable
|
||||
? "unavailable"
|
||||
: "neutral"
|
||||
}
|
||||
className="border-border bg-card mx-auto flex w-full max-w-md flex-col gap-4 rounded-xl border p-4 text-left md:p-6"
|
||||
>
|
||||
<header className="flex items-start gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="from-primary to-brand-pink flex size-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
|
||||
>
|
||||
<Sparkles className="text-background size-4" />
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<h2
|
||||
id="onboarding-ai-panel-title"
|
||||
className="text-foreground text-base font-semibold tracking-tight"
|
||||
data-ai-state={
|
||||
aiProvider === undefined
|
||||
? undefined
|
||||
: sharedKeyNote
|
||||
? "consent"
|
||||
: offerShared
|
||||
? "offer"
|
||||
: sharedUnavailable
|
||||
? "unavailable"
|
||||
: "neutral"
|
||||
}
|
||||
className="border-border bg-card mx-auto flex w-full max-w-md flex-col gap-4 rounded-xl border p-4 text-left md:p-6"
|
||||
>
|
||||
<header className="flex items-start gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="from-primary to-brand-pink flex size-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
|
||||
>
|
||||
{t("onboarding.ai.panelTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.panelIntro")}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<Sparkles className="text-background size-4" />
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<h2
|
||||
id="onboarding-ai-panel-title"
|
||||
className="text-foreground text-base font-semibold tracking-tight"
|
||||
>
|
||||
{t("onboarding.ai.panelTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.panelIntro")}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{sampleOpen ? (
|
||||
<SampleBriefingCard />
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
data-slot="onboarding-ai-show-sample"
|
||||
onClick={() => setSampleOpen(true)}
|
||||
>
|
||||
{t("onboarding.ai.showSample")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{sharedKeyNote ? (
|
||||
<p
|
||||
data-slot="onboarding-ai-shared-key"
|
||||
className="text-foreground bg-primary/5 border-primary/20 rounded-lg border px-3 py-2 text-sm leading-relaxed"
|
||||
>
|
||||
{justGranted
|
||||
? t("onboarding.ai.offer.granted")
|
||||
: t("onboarding.ai.sharedKeyNote")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{offerShared ? (
|
||||
<div
|
||||
data-slot="onboarding-ai-offer"
|
||||
className="border-primary/20 bg-primary/5 space-y-2.5 rounded-lg border px-3 py-3"
|
||||
>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.offer.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.offer.body")}
|
||||
</p>
|
||||
{sampleOpen ? (
|
||||
<SampleBriefingCard />
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9 w-full"
|
||||
data-slot="onboarding-ai-offer-grant"
|
||||
disabled={grantConsent.isPending}
|
||||
onClick={() => grantConsent.mutate()}
|
||||
className="w-full"
|
||||
data-slot="onboarding-ai-show-sample"
|
||||
onClick={() => setSampleOpen(true)}
|
||||
>
|
||||
{t("onboarding.ai.offer.cta")}
|
||||
{t("onboarding.ai.showSample")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{sharedUnavailable ? (
|
||||
<div
|
||||
data-slot="onboarding-ai-unavailable"
|
||||
className="border-border bg-muted/40 space-y-1 rounded-lg border px-3 py-3"
|
||||
>
|
||||
{sharedKeyNote ? (
|
||||
<p
|
||||
data-slot="onboarding-ai-shared-key"
|
||||
className="text-foreground bg-primary/5 border-primary/20 rounded-lg border px-3 py-2 text-sm leading-relaxed"
|
||||
>
|
||||
{justGranted
|
||||
? t("onboarding.ai.offer.granted")
|
||||
: t("onboarding.ai.sharedKeyNote")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{offerShared ? (
|
||||
<div
|
||||
data-slot="onboarding-ai-offer"
|
||||
className="border-primary/20 bg-primary/5 space-y-2.5 rounded-lg border px-3 py-3"
|
||||
>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.offer.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.offer.body")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-9 w-full"
|
||||
data-slot="onboarding-ai-offer-grant"
|
||||
disabled={grantConsent.isPending}
|
||||
onClick={() => grantConsent.mutate()}
|
||||
>
|
||||
{t("onboarding.ai.offer.cta")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sharedUnavailable ? (
|
||||
<div
|
||||
data-slot="onboarding-ai-unavailable"
|
||||
className="border-border bg-muted/40 space-y-1 rounded-lg border px-3 py-3"
|
||||
>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.unavailable.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.unavailable.body")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.unavailable.title")}
|
||||
{t("onboarding.ai.ladderTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.unavailable.body")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.ladderTitle")}
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
{/* Local first — the calm, private default — then BYOK, then
|
||||
<ul className="space-y-2.5">
|
||||
{/* Local first — the calm, private default — then BYOK, then
|
||||
the subscription/OAuth path with its training caveat. Same
|
||||
ordering + vendor-blind framing as the shipped document-
|
||||
provider governance. */}
|
||||
<li className="space-y-0.5">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.ladderLocalTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.ladderLocalBody")}
|
||||
</p>
|
||||
</li>
|
||||
<li className="space-y-0.5">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.ladderByokTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.ladderByokBody")}
|
||||
</p>
|
||||
</li>
|
||||
<li className="space-y-0.5">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.ladderOauthTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.ladderOauthBody")}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<li className="space-y-0.5">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.ladderLocalTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.ladderLocalBody")}
|
||||
</p>
|
||||
</li>
|
||||
<li className="space-y-0.5">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.ladderByokTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.ladderByokBody")}
|
||||
</p>
|
||||
</li>
|
||||
<li className="space-y-0.5">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.ladderOauthTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.ladderOauthBody")}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p
|
||||
data-slot="onboarding-ai-keyless"
|
||||
className="text-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{t("onboarding.ai.keylessLine")}
|
||||
</p>
|
||||
<p
|
||||
data-slot="onboarding-ai-keyless"
|
||||
className="text-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{t("onboarding.ai.keylessLine")}
|
||||
</p>
|
||||
|
||||
<MedicalDisclaimer variant="dataPosture" />
|
||||
<MedicalDisclaimer variant="dataPosture" />
|
||||
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
className="text-primary text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("onboarding.ai.setupCta")}
|
||||
</Link>
|
||||
</section>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
className="text-primary text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("onboarding.ai.setupCta")}
|
||||
</Link>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="flex w-full max-w-xs flex-col gap-2">
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAiProviderState } from "@/hooks/use-ai-capability";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -111,15 +112,6 @@ interface NotificationsPreferences {
|
||||
channels?: NotificationChannel[];
|
||||
}
|
||||
|
||||
interface AiProviderStatus {
|
||||
/**
|
||||
* True iff any provider can serve the user — personal key, local
|
||||
* model, OAuth sign-in, or the operator's shared key. Mirrors the
|
||||
* `insights` checklist row's `done` predicate.
|
||||
*/
|
||||
aiAvailable?: boolean;
|
||||
}
|
||||
|
||||
function readDismissedSet(): Set<ChecklistItemId> {
|
||||
if (typeof window === "undefined") return new Set();
|
||||
try {
|
||||
@@ -270,19 +262,11 @@ export function GettingStartedChecklist() {
|
||||
enabled: checklistRelevant,
|
||||
});
|
||||
|
||||
// v1.28 — the "Turn on insights" row self-satisfies from real provider
|
||||
// state. `/api/user/ai-provider` reports `aiAvailable` presence-only
|
||||
// (personal key OR local model OR OAuth OR the operator's shared key),
|
||||
// so connecting a provider — or landing on a shared-key deployment —
|
||||
// flips the row done with no client-side credential handling. Gated on
|
||||
// `checklistRelevant` so it only fetches while the card can render.
|
||||
const { data: aiProviderData } = useQuery<AiProviderStatus>({
|
||||
queryKey: queryKeys.userAiProvider(),
|
||||
queryFn: async () => {
|
||||
return apiGet("/api/user/ai-provider");
|
||||
},
|
||||
enabled: checklistRelevant,
|
||||
});
|
||||
// The AI row reads the provider state `/api/auth/me` already carries:
|
||||
// present only while AI can be set up from here (`canConfigure`), done once
|
||||
// any provider is configured (a personal key, a local model, an OAuth
|
||||
// sign-in or the operator's shared key). No request of its own.
|
||||
const aiProvider = useAiProviderState();
|
||||
|
||||
// v1.5 perf audit: skip these two fetches once the card can't render at
|
||||
// all (`checklistRelevant`, same gate the meds/AI queries above use).
|
||||
@@ -351,7 +335,8 @@ export function GettingStartedChecklist() {
|
||||
const notificationsConfigured = (notificationsData?.channels ?? []).some(
|
||||
(channel) => channel?.enabled === true,
|
||||
);
|
||||
const insightsConfigured = aiProviderData?.aiAvailable === true;
|
||||
const insightsConfigured = aiProvider.configured;
|
||||
const aiConfigurable = aiProvider.canConfigure;
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
@@ -366,6 +351,7 @@ export function GettingStartedChecklist() {
|
||||
dataSourceConnected,
|
||||
notificationsConfigured,
|
||||
insightsConfigured,
|
||||
aiConfigurable,
|
||||
dismissedIds,
|
||||
upcomingVisitCount: upcomingVisitCount ?? 0,
|
||||
managedProfileCount,
|
||||
@@ -387,6 +373,7 @@ export function GettingStartedChecklist() {
|
||||
dataSourceConnected,
|
||||
notificationsConfigured,
|
||||
insightsConfigured,
|
||||
aiConfigurable,
|
||||
dismissedIds,
|
||||
upcomingVisitCount,
|
||||
managedProfileCount,
|
||||
|
||||
@@ -44,6 +44,23 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// The AI capability map on `/api/auth/me`: everything available, a provider
|
||||
// configured, nothing switched off by the operator. The notices and the
|
||||
// setup hint above the provider card have their own suites.
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
useAiCapabilityMap: () => null,
|
||||
useAiProviderState: () => ({
|
||||
configured: true,
|
||||
managedBy: "user",
|
||||
canConfigure: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Stable mocks for every TanStack Query the section reads. The chain
|
||||
// fixture mirrors the default order (codex first) so the rows render in
|
||||
// a known order; insights settings + ai-provider GET return enough to
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* What the Coach stored stays readable and deletable while the Coach is off.
|
||||
*
|
||||
* Coach conversations and the facts the Coach remembered are the person's
|
||||
* own records, not caches. Before v1.39 Settings → Coach hid the memory card
|
||||
* together with the Coach's tuning as soon as the Coach was hidden, and with
|
||||
* the Coach module off the whole page was out of reach, so a person could
|
||||
* neither see nor erase what had been stored. Now:
|
||||
*
|
||||
* - Settings → Coach always shows the memory (facts and conversations),
|
||||
* whatever `disableCoach` says;
|
||||
* - Settings → AI shows the same cards through `<StoredCoachMemory>` while
|
||||
* Settings → Coach is not reachable, and only when rows exist.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const authState = vi.hoisted(() => ({ disableCoach: true }));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
disableCoach: authState.disableCoach,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
// Past hydration: the section's own gates read the account only once
|
||||
// mounted, and a static render is otherwise the pre-hydration pass, where
|
||||
// every card shows and the test could not tell a gate from its absence.
|
||||
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/coach",
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { CoachSection } from "../coach-section";
|
||||
import { StoredCoachMemory } from "../coach-memory-section";
|
||||
|
||||
const FACT = {
|
||||
id: "fact-1",
|
||||
category: "goal",
|
||||
text: "Wants to walk to work twice a week",
|
||||
confidence: 0.9,
|
||||
createdAt: "2026-09-01T08:00:00.000Z",
|
||||
};
|
||||
const CONVERSATION = {
|
||||
id: "conv-1",
|
||||
title: "Morning blood pressure",
|
||||
createdAt: "2026-09-01T08:00:00.000Z",
|
||||
updatedAt: "2026-09-01T08:05:00.000Z",
|
||||
messageCount: 4,
|
||||
fenced: false,
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
function client(stored: { facts: unknown[]; conversations: unknown[] }) {
|
||||
const c = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0, staleTime: Infinity } },
|
||||
});
|
||||
c.setQueryData(queryKeys.coachFacts(), stored.facts);
|
||||
c.setQueryData(queryKeys.coachConversationHistory(""), {
|
||||
pages: [{ conversations: stored.conversations, nextCursor: null }],
|
||||
pageParams: [null],
|
||||
});
|
||||
return c;
|
||||
}
|
||||
|
||||
function render(
|
||||
node: React.ReactNode,
|
||||
stored: { facts: unknown[]; conversations: unknown[] },
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client(stored)}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Everything is seeded; a background refetch must not reach the network.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("{}", { status: 500 })),
|
||||
);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("Settings → Coach keeps the Coach memory while the Coach is hidden", () => {
|
||||
it("shows the facts and the conversations with Hide Coach on", () => {
|
||||
authState.disableCoach = true;
|
||||
const html = render(<CoachSection />, {
|
||||
facts: [FACT],
|
||||
conversations: [CONVERSATION],
|
||||
});
|
||||
expect(html).toContain('data-testid="settings-coach-memory-card"');
|
||||
expect(html).toContain("Wants to walk to work twice a week");
|
||||
expect(html).toContain('data-testid="settings-coach-conversations-card"');
|
||||
expect(html).toContain("Morning blood pressure");
|
||||
// Deletable: the per-row controls are there.
|
||||
expect(html).toContain('data-slot="settings-coach-memory-forget"');
|
||||
expect(html).toContain('data-slot="settings-coach-conversation-delete"');
|
||||
// The Coach's tuning stays hidden with the Coach.
|
||||
expect(html).not.toContain('id="coach-nudge"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<StoredCoachMemory> (Settings → AI while the Coach page is out of reach)", () => {
|
||||
it("shows what is stored, readable and deletable", () => {
|
||||
const html = render(<StoredCoachMemory isAuthenticated />, {
|
||||
facts: [FACT],
|
||||
conversations: [CONVERSATION],
|
||||
});
|
||||
expect(html).toContain('data-testid="settings-coach-memory-card"');
|
||||
expect(html).toContain('data-testid="settings-coach-conversations-card"');
|
||||
expect(html).toContain('data-slot="settings-coach-conversation-open"');
|
||||
expect(html).toContain('data-slot="settings-coach-conversation-delete"');
|
||||
});
|
||||
|
||||
it("shows only the card that has rows", () => {
|
||||
const html = render(<StoredCoachMemory isAuthenticated />, {
|
||||
facts: [],
|
||||
conversations: [CONVERSATION],
|
||||
});
|
||||
expect(html).not.toContain('data-testid="settings-coach-memory-card"');
|
||||
expect(html).toContain('data-testid="settings-coach-conversations-card"');
|
||||
});
|
||||
|
||||
it("renders nothing for somebody the Coach never stored anything for", () => {
|
||||
const html = render(<StoredCoachMemory isAuthenticated />, {
|
||||
facts: [],
|
||||
conversations: [],
|
||||
});
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }),
|
||||
useInfiniteQuery: () => ({ data: undefined, isLoading: false }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
@@ -13,8 +13,13 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
user: { modules: modulesRef.value },
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-feature-flags", () => ({
|
||||
useFeatureFlags: () => ({ briefing: true }),
|
||||
const briefingRef: { available: boolean } = { available: true };
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: briefingRef.available,
|
||||
reason: briefingRef.available ? null : "user_disabled",
|
||||
onDeviceAllowed: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-insights-layout", () => ({
|
||||
useInsightsLayoutQuery: () => ({
|
||||
@@ -63,4 +68,12 @@ describe("<InsightsOverviewArrangeSection> module gate", () => {
|
||||
it("marks nothing for AI analysis off", () => {
|
||||
expect(gatedFor({ insights: false })).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks only the daily briefing when the briefing is unavailable", () => {
|
||||
// The period review renders its composed narrative whatever the AI
|
||||
// state, so it stays a live row.
|
||||
briefingRef.available = false;
|
||||
expect(gatedFor({})).toEqual(["daily-briefing"]);
|
||||
briefingRef.available = true;
|
||||
});
|
||||
});
|
||||
@@ -63,21 +63,18 @@ vi.mock("@/hooks/use-auth", async () => {
|
||||
return { ...actual, useAuth: () => authSpy() };
|
||||
});
|
||||
|
||||
// The hub now renders a live coach switch, so it reads the operator assistant
|
||||
// flag matrix (`flags.coach`) to decide whether the operator killed Coach
|
||||
// server-wide. Mock the hook directly so the SSR pass doesn't need a
|
||||
// `<QueryClientProvider>`; default all-on.
|
||||
import type { AssistantFlagSet } from "@/hooks/use-feature-flags";
|
||||
const ALL_ON_FLAGS: AssistantFlagSet = {
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
documentAi: true,
|
||||
// The hub renders a live coach switch and reads the `coach` AI capability to
|
||||
// decide whether the operator switched the Coach off server-wide. Mock the
|
||||
// hook directly so the SSR pass doesn't need a `<QueryClientProvider>`.
|
||||
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
|
||||
const COACH_AVAILABLE: AiCapabilityState = {
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
};
|
||||
const flagsSpy = vi.fn<() => AssistantFlagSet>(() => ALL_ON_FLAGS);
|
||||
vi.mock("@/hooks/use-feature-flags", () => ({
|
||||
useFeatureFlags: () => flagsSpy(),
|
||||
const coachSpy = vi.fn<() => AiCapabilityState>(() => COACH_AVAILABLE);
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => coachSpy(),
|
||||
}));
|
||||
|
||||
function buildUser(
|
||||
@@ -133,7 +130,7 @@ beforeEach(() => {
|
||||
user: buildUser({}),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
flagsSpy.mockImplementation(() => ALL_ON_FLAGS);
|
||||
coachSpy.mockImplementation(() => COACH_AVAILABLE);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -295,7 +292,11 @@ describe("<ModulesSection>", () => {
|
||||
|
||||
it("disables the switch + shows a hint when the operator killed Coach", () => {
|
||||
// Operator assistant master flag off → coach is unavailable server-wide.
|
||||
flagsSpy.mockImplementation(() => ({ ...ALL_ON_FLAGS, coach: false }));
|
||||
coachSpy.mockImplementation(() => ({
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
}));
|
||||
const html = render();
|
||||
const coachTag = html.match(
|
||||
/<button[^>]*id="module-toggle-coach"[^>]*>/,
|
||||
|
||||
@@ -45,6 +45,22 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
// The AI capability map on `/api/auth/me`: everything available, a provider
|
||||
// configured, nothing switched off by the operator.
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
useAiCapabilityMap: () => null,
|
||||
useAiProviderState: () => ({
|
||||
configured: true,
|
||||
managedBy: "user",
|
||||
canConfigure: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Stub the auth hook — sections check `isAuthenticated` to enable queries.
|
||||
// v1.16.4 — AccountSection gates its content on `useMounted()` so the
|
||||
// SSR pass and the hydration render agree (React #418 family). The
|
||||
|
||||
@@ -19,11 +19,15 @@
|
||||
*/
|
||||
|
||||
import { AiInsightsCard } from "@/components/settings/ai/ai-insights-card";
|
||||
import { OperatorOffNotice } from "@/components/settings/ai/operator-off-notice";
|
||||
import { StoredCoachMemory } from "@/components/settings/coach-memory-section";
|
||||
import { AiSetupHint } from "@/components/insights/ai-setup-hint";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
|
||||
export function AiSection() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
// v1.16.6 — the auth query can resolve before this boundary
|
||||
// hydrates; every `disabled={!isAuthenticated}` binding in the child
|
||||
// cards would then disagree with the SSR HTML (React #418). Gate the
|
||||
@@ -36,5 +40,23 @@ export function AiSection() {
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` in the route; this body is the single provider
|
||||
// card. (The "About me" context moved to Settings → Account in v1.18.1 D8.)
|
||||
return <AiInsightsCard isAuthenticated={authed} />;
|
||||
//
|
||||
// v1.39 — above the provider card: what the operator has switched off (so
|
||||
// nothing below reads as broken), and the one calm setup hint while no
|
||||
// provider is configured. Below it: what the Coach stored, while Settings →
|
||||
// Coach is not reachable, so it can always be read and deleted.
|
||||
const coachSettingsReachable = isSurfaceVisible(
|
||||
"settings:coach",
|
||||
user?.modules,
|
||||
);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<OperatorOffNotice />
|
||||
<AiSetupHint surface="settings" />
|
||||
<AiInsightsCard isAuthenticated={authed} />
|
||||
{authed && !coachSettingsReachable ? (
|
||||
<StoredCoachMemory isAuthenticated={authed} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,18 @@
|
||||
* without an active receipt, so every AI surface falls back to its
|
||||
* no-consent state on the next call. Granting posts an affirmative intent,
|
||||
* which is the consent act itself and therefore may supersede an earlier
|
||||
* revocation; the silent mount heal never does. Nothing already stored is
|
||||
* deleted by a withdrawal; that is the export/erase path in Data & Privacy,
|
||||
* and the copy says so rather than implying more than it does.
|
||||
* revocation; the silent mount heal never does.
|
||||
*
|
||||
* v1.39 — a withdrawal also deletes what the app can write again: the notes
|
||||
* a model wrote from the person's data (status notes, the daily briefing,
|
||||
* model-written period summaries, arrival lines, workout notes), in the same
|
||||
* step as the revoke. The person's own records stay: Coach conversations,
|
||||
* what the Coach remembered, plans and document summaries, readable and
|
||||
* deletable by them. The confirm copy says exactly that.
|
||||
*
|
||||
* The narrower consent for reading documents (`ai_extraction`) gets its own
|
||||
* row once one is on file, with its own withdrawal. It is granted where a
|
||||
* document is read, so this is where it is taken back.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { useState } from "react";
|
||||
@@ -69,10 +78,12 @@ export function AiConsentCard({
|
||||
onSuccess: async () => {
|
||||
setConfirming(false);
|
||||
// The receipt gates every AI surface, so anything that reads consent
|
||||
// state has to re-resolve — not just this card.
|
||||
// state has to re-resolve — not just this card. `/me` carries the
|
||||
// resolved capabilities every AI surface renders from.
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsProviderChain(),
|
||||
});
|
||||
@@ -96,6 +107,7 @@ export function AiConsentCard({
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsProviderChain(),
|
||||
});
|
||||
@@ -217,6 +229,119 @@ export function AiConsentCard({
|
||||
{t("settings.ai.consent.grantError")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<DocumentReadingConsentRow isAuthenticated={isAuthenticated} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The consent for reading documents (`ai_extraction`), shown only while one
|
||||
* is on file. Withdrawing it stops documents, lab report scans and medication
|
||||
* text from being sent for reading at once; it deletes nothing, because what
|
||||
* a read produced (a document summary, extracted values) is the person's own
|
||||
* record.
|
||||
*/
|
||||
function DocumentReadingConsentRow({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_extraction"),
|
||||
queryFn: async () => {
|
||||
const res = await apiFetchRaw(
|
||||
"/api/consent/ai/latest?kind=ai_extraction",
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return (json.data?.receipt ?? null) as ConsentReceiptWire;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationKey: queryKeys.aiConsentReceipt("ai_extraction"),
|
||||
mutationFn: async () => {
|
||||
const res = await apiFetchRaw(
|
||||
"/api/consent/ai/latest?kind=ai_extraction",
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("revoke failed");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setConfirming(false);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_extraction"),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
},
|
||||
});
|
||||
|
||||
const active = data && data.revokedAt === null ? data : null;
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="ai-consent-document-reading"
|
||||
className="border-border space-y-2 border-t pt-3"
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.ai.consent.documentReading.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.ai.consent.documentReading.activeSince", {
|
||||
date: formatDateTime(active.signedAt),
|
||||
})}
|
||||
</p>
|
||||
{confirming ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-muted-foreground w-full text-xs">
|
||||
{t("settings.ai.consent.documentReading.confirmBody")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
data-slot="ai-consent-document-reading-withdraw-confirm"
|
||||
onClick={() => revoke.mutate()}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
{revoke.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("settings.ai.consent.confirmWithdraw")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="ai-consent-document-reading-withdraw"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("settings.ai.consent.withdraw")}
|
||||
</Button>
|
||||
)}
|
||||
{revoke.isError ? (
|
||||
<p className="text-destructive text-xs" role="alert">
|
||||
{t("settings.ai.consent.error")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { ShieldAlert } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -31,6 +32,12 @@ interface AutoReadPref {
|
||||
export function AutoReadCard() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
// With the operator's "Reading documents" switch (or all AI) off, nothing
|
||||
// would be read whatever this says, so the switch is shown disabled with
|
||||
// the reason instead of a control that silently does nothing. Turning an
|
||||
// already-on preference off stays possible.
|
||||
const documentAi = useAiCapability("documentAi");
|
||||
const operatorOff = documentAi.reason === "operator_disabled";
|
||||
|
||||
// Whether the honesty confirm is currently shown (user flipped the switch ON
|
||||
// but has not yet acknowledged the trade). Off→on reveals it; the write only
|
||||
@@ -54,10 +61,12 @@ export function AutoReadCard() {
|
||||
queryClient.setQueryData(queryKeys.documentsAutoAiRead(), result);
|
||||
// Flipping the toggle changes whether an ambient/per-document read
|
||||
// egresses without a per-document consent step — refresh the capability
|
||||
// probe the vault UI reads.
|
||||
// probe the vault UI reads, and `/me`, whose `documentAi` capability
|
||||
// reflects the consent the toggle records.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.inboundDocumentAiCapability(),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
setPendingEnable(false);
|
||||
},
|
||||
});
|
||||
@@ -92,13 +101,22 @@ export function AutoReadCard() {
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={busy}
|
||||
disabled={busy || (operatorOff && !enabled)}
|
||||
onCheckedChange={onSwitch}
|
||||
aria-label={t("settings.ai.autoRead.title")}
|
||||
data-testid="documents-auto-read-enable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{operatorOff ? (
|
||||
<p
|
||||
data-slot="documents-auto-read-operator-off"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("settings.ai.operatorOff.autoRead")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{pendingEnable ? (
|
||||
<div
|
||||
data-slot="documents-auto-read-confirm"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { PowerOff } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useAiCapabilityMap } from "@/hooks/use-ai-capability";
|
||||
import {
|
||||
AI_CAPABILITIES,
|
||||
AI_CAPABILITY_KEYS,
|
||||
AI_OPERATOR_SWITCHES,
|
||||
type AiOperatorSwitch,
|
||||
} from "@/lib/ai/capabilities/types";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Says, in Settings → AI, what the person running this server has switched
|
||||
* off, so the settings below never read as broken.
|
||||
*
|
||||
* Read from the resolved capabilities on `/api/auth/me`: a capability whose
|
||||
* reason is `operator_disabled` names the operator switch that covers it, and
|
||||
* the notice lists those switches by the names the admin panel uses. With
|
||||
* every switch off (the master switch) it says AI is off on this server. With
|
||||
* none off it renders nothing. It never offers a control: nothing on this
|
||||
* screen can change an operator's switch.
|
||||
*/
|
||||
export function OperatorOffNotice() {
|
||||
const { t } = useTranslations();
|
||||
const capabilities = useAiCapabilityMap();
|
||||
if (!capabilities) return null;
|
||||
|
||||
const off = new Set<AiOperatorSwitch>();
|
||||
for (const key of AI_CAPABILITY_KEYS) {
|
||||
if (capabilities[key]?.reason === "operator_disabled") {
|
||||
off.add(AI_CAPABILITIES[key].operatorSwitch);
|
||||
}
|
||||
}
|
||||
if (off.size === 0) return null;
|
||||
|
||||
const allOff = AI_OPERATOR_SWITCHES.every((s) => off.has(s));
|
||||
const names = AI_OPERATOR_SWITCHES.filter((s) => off.has(s)).map((s) =>
|
||||
t(`admin.assistant.${s}.title`),
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="ai-operator-off-notice">
|
||||
<SettingsCardHeader
|
||||
icon={PowerOff}
|
||||
title={
|
||||
allOff
|
||||
? t("settings.ai.operatorOff.allTitle")
|
||||
: t("settings.ai.operatorOff.someTitle")
|
||||
}
|
||||
/>
|
||||
<p className="text-foreground text-sm">
|
||||
{allOff
|
||||
? t("settings.ai.operatorOff.allBody")
|
||||
: t("settings.ai.operatorOff.someBody", {
|
||||
switches: names.join(", "),
|
||||
})}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { apiFetchRaw, apiPut } from "@/lib/api/api-fetch";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
@@ -39,6 +40,12 @@ export function RuntimeActionsRow({
|
||||
onPrivacyChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
// "Regenerate" writes the daily briefing, so it is offered only while the
|
||||
// `briefing` capability is available (operator switch, AI analysis opt-out,
|
||||
// provider, consent). The connection test stays: it is how a provider gets
|
||||
// checked before anything else can be on.
|
||||
const briefing = useAiCapability("briefing");
|
||||
const showRegenerate = canRegenerate && briefing.available;
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testMsg, setTestMsg] = useState<string | null>(null);
|
||||
@@ -80,13 +87,18 @@ export function RuntimeActionsRow({
|
||||
setTestMsg(t("settings.ai.testUnexpectedResponse"));
|
||||
return;
|
||||
}
|
||||
// 4xx config errors still arrive via the error envelope.
|
||||
// 4xx config errors still arrive via the error envelope. A refusal
|
||||
// because the operator switched AI off says so in words.
|
||||
if (!res.ok) {
|
||||
setTestOk(false);
|
||||
const code = (json as { meta?: { errorCode?: unknown } | null }).meta
|
||||
?.errorCode;
|
||||
setTestMsg(
|
||||
t("settings.ai.testFailedShort", {
|
||||
message: json.error ?? `HTTP ${res.status}`,
|
||||
}),
|
||||
typeof code === "string" && code.startsWith("assistant.disabled.")
|
||||
? t("settings.ai.operatorOff.testRefused")
|
||||
: t("settings.ai.testFailedShort", {
|
||||
message: json.error ?? `HTTP ${res.status}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -204,7 +216,7 @@ export function RuntimeActionsRow({
|
||||
// setting and every status line (last-generated, test / regenerate
|
||||
// result) sit ABOVE it, never under it.
|
||||
<div className="space-y-3">
|
||||
{canRegenerate && (
|
||||
{showRegenerate && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="pr-2">
|
||||
@@ -264,7 +276,7 @@ export function RuntimeActionsRow({
|
||||
)}
|
||||
{t("settings.ai.testProvider")}
|
||||
</Button>
|
||||
{canRegenerate && (
|
||||
{showRegenerate && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -44,6 +44,11 @@ import { HealthProfileFactsManager } from "@/components/records/health-profile-f
|
||||
|
||||
export function AnamnesisSection() {
|
||||
const { t } = useTranslations();
|
||||
// The inclusion card and the about-me note only feed AI surfaces, so they
|
||||
// follow the person's two AI opt-outs: the Coach module and "AI analysis"
|
||||
// (the `insights` module, which since v1.39 governs model-written text and
|
||||
// nothing else). They are opt-outs, not capabilities, on purpose: somebody
|
||||
// without a provider yet can still write the note the AI will read later.
|
||||
const coachEnabled = useModuleEnabled("coach");
|
||||
const insightsEnabled = useModuleEnabled("insights");
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { MessagesSquare, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ConversationTranscript } from "@/components/insights/coach-panel/conversation-transcript";
|
||||
import {
|
||||
useCoachConversationHistory,
|
||||
useDeleteCoachConversationWithUndo,
|
||||
} from "@/components/insights/coach-panel/use-coach";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
|
||||
/**
|
||||
* The Coach's stored conversations, readable and deletable from Settings.
|
||||
*
|
||||
* A conversation is the person's own record, not a cache, so it stays
|
||||
* reachable whatever the Coach's state: switched off by the operator, hidden
|
||||
* by the person, without a provider or without consent. Each row opens its
|
||||
* transcript in place and can be deleted (with the same undo window the
|
||||
* conversation list uses). Reading and deleting never ask the Coach; the
|
||||
* routes behind them are data routes.
|
||||
*
|
||||
* `hideWhenEmpty`: the card renders nothing when there is nothing stored.
|
||||
* Settings → AI mounts it that way while the Coach is off, so it appears only
|
||||
* when there is something to see.
|
||||
*/
|
||||
export function CoachConversationsMemoryCard({
|
||||
isAuthenticated,
|
||||
hideWhenEmpty = false,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
hideWhenEmpty?: boolean;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const history = useCoachConversationHistory({ enabled: isAuthenticated });
|
||||
const { pendingDeleteIds, requestDelete, undoDelete } =
|
||||
useDeleteCoachConversationWithUndo();
|
||||
|
||||
const visible = useMemo(
|
||||
() => history.conversations.filter((c) => !pendingDeleteIds.has(c.id)),
|
||||
[history.conversations, pendingDeleteIds],
|
||||
);
|
||||
|
||||
if (hideWhenEmpty && (history.isLoading || history.isError)) return null;
|
||||
if (hideWhenEmpty && visible.length === 0) return null;
|
||||
|
||||
function remove(id: string) {
|
||||
requestDelete(id);
|
||||
if (openId === id) setOpenId(null);
|
||||
toast.success(t("insights.coach.historyDeleted"), {
|
||||
action: { label: t("common.undo"), onClick: () => undoDelete(id) },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
aria-labelledby="settings-coach-conversations-title"
|
||||
data-testid="settings-coach-conversations-card"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={MessagesSquare}
|
||||
titleId="settings-coach-conversations-title"
|
||||
title={t("settings.ai.coachConversations.title")}
|
||||
description={t("settings.ai.coachConversations.description")}
|
||||
/>
|
||||
{history.isError ? (
|
||||
<QueryErrorRow onRetry={() => void history.refetch()} />
|
||||
) : visible.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{history.isLoading
|
||||
? t("common.loading")
|
||||
: t("insights.coach.historyEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{visible.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
data-testid="settings-coach-conversation"
|
||||
className="border-border bg-background rounded-lg border"
|
||||
>
|
||||
<div className="flex items-center gap-2 p-1.5 pl-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setOpenId((current) => (current === c.id ? null : c.id))
|
||||
}
|
||||
aria-expanded={openId === c.id}
|
||||
data-slot="settings-coach-conversation-open"
|
||||
className="flex min-h-11 min-w-0 flex-1 flex-col justify-center text-left"
|
||||
>
|
||||
<span className="truncate text-sm font-medium">
|
||||
{c.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatRelativeTime(c.updatedAt, t, locale)}
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
onClick={() => remove(c.id)}
|
||||
aria-label={t("insights.coach.historyDeleteAria")}
|
||||
data-slot="settings-coach-conversation-delete"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{openId === c.id ? <ConversationTranscript id={c.id} /> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{history.hasNextPage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 self-start sm:min-h-9"
|
||||
disabled={history.isFetchingNextPage}
|
||||
onClick={() => void history.fetchNextPage()}
|
||||
>
|
||||
{t("settings.ai.coachConversations.more")}
|
||||
</Button>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -14,9 +14,14 @@
|
||||
*
|
||||
* Reads unwrap `(await res.json()).data` per the envelope convention;
|
||||
* every read/write routes its key through `queryKeys.coachFacts()` so
|
||||
* a forget invalidates the list. Gated on `!user.disableCoach` so the
|
||||
* panel mirrors the rest of the Coach surface — hiding the Coach hides
|
||||
* its memory controls too.
|
||||
* a forget invalidates the list.
|
||||
*
|
||||
* 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
|
||||
* facts routes are data routes and ask no AI gate. Settings → Coach always
|
||||
* 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";
|
||||
@@ -45,6 +50,7 @@ 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 { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card";
|
||||
|
||||
/** Closed enum mirrored from the server `CoachFact.category` column. */
|
||||
const FACT_CATEGORIES = [
|
||||
@@ -81,8 +87,11 @@ async function fetchFacts(): Promise<CoachFact[]> {
|
||||
|
||||
export function CoachMemorySection({
|
||||
isAuthenticated,
|
||||
hideWhenEmpty = false,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
/** Render nothing while there are no stored facts (the Coach-off mount). */
|
||||
hideWhenEmpty?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -138,6 +147,9 @@ export function CoachMemorySection({
|
||||
})).filter((g) => g.items.length > 0);
|
||||
}, [facts]);
|
||||
|
||||
if (hideWhenEmpty && (query.isPending || query.isError)) return null;
|
||||
if (hideWhenEmpty && facts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
@@ -279,3 +291,25 @@ export function CoachMemorySection({
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the Coach stored, for Settings → AI while Settings → Coach is not
|
||||
* reachable (the Coach switched off by the operator or hidden by the person).
|
||||
* Each card renders only when it has rows, so a person who never used the
|
||||
* Coach sees nothing here.
|
||||
*/
|
||||
export function StoredCoachMemory({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<CoachMemorySection isAuthenticated={isAuthenticated} hideWhenEmpty />
|
||||
<CoachConversationsMemoryCard
|
||||
isAuthenticated={isAuthenticated}
|
||||
hideWhenEmpty
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,9 +16,11 @@
|
||||
* notification).
|
||||
* 4. Coach memory — durable-fact review + forget controls.
|
||||
*
|
||||
* The nav entry is module-gated on `coach`. The preference + nudge + memory
|
||||
* cards keep their own content gate on `!user.disableCoach` (mirrored from the
|
||||
* old AI section): hiding the Coach hides its tuning + memory controls too.
|
||||
* The nav entry is module-gated on `coach`. The preference + nudge +
|
||||
* reminder cards keep their own content gate on `!user.disableCoach`: hiding
|
||||
* the Coach hides its tuning. The memory (facts and stored conversations) is
|
||||
* never gated: it is the person's own record, readable and deletable whatever
|
||||
* the Coach's state.
|
||||
*
|
||||
* The "about me" context lives in the AI section, not here — the daily
|
||||
* briefing reads it too, so it is not a Coach-only setting.
|
||||
@@ -29,6 +31,7 @@
|
||||
|
||||
import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card";
|
||||
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
|
||||
import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card";
|
||||
import { CoachRemindersSection } from "@/components/settings/coach-reminders-section";
|
||||
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
|
||||
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
|
||||
@@ -62,7 +65,9 @@ export function CoachSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{coachEnabled && <CoachMemorySection isAuthenticated={authed} />}
|
||||
<CoachMemorySection isAuthenticated={authed} />
|
||||
|
||||
<CoachConversationsMemoryCard isAuthenticated={authed} />
|
||||
|
||||
{coachEnabled && <CoachRemindersSection isAuthenticated={authed} />}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LayoutGrid, Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
|
||||
import { InsightsEditMode } from "@/components/insights/insights-edit-mode";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
@@ -35,15 +35,15 @@ import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const flags = useFeatureFlags();
|
||||
// The daily briefing is model-written: its row is live only while the
|
||||
// `briefing` capability is available. The period review always renders
|
||||
// (its narrative is composed from the numbers), so it stays a live row.
|
||||
const briefing = useAiCapability("briefing");
|
||||
const { layout, isLoading } = useInsightsLayoutQuery(isAuthenticated);
|
||||
|
||||
const gatedOffSectionIds = useMemo(() => {
|
||||
const gated = new Set<InsightsSectionId>();
|
||||
if (!flags.briefing) {
|
||||
gated.add("daily-briefing");
|
||||
gated.add("period-review");
|
||||
}
|
||||
if (!briefing.available) gated.add("daily-briefing");
|
||||
// A block whose module is off cannot appear on the overview, so its row
|
||||
// is not offered as a live toggle. Read from the one surface map the
|
||||
// overview renders from (`overview:<id>`); cycle follows the resolved
|
||||
@@ -52,7 +52,7 @@ export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
|
||||
if (!isSurfaceVisible(`overview:${id}`, user?.modules)) gated.add(id);
|
||||
}
|
||||
return gated;
|
||||
}, [flags.briefing, user?.modules]);
|
||||
}, [briefing.available, user?.modules]);
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
* off `useAuth().user.modules`.
|
||||
*
|
||||
* Operator precedence stays honest: a module the operator turned off
|
||||
* server-wide (the module-availability blob, plus the assistant master flag
|
||||
* `flags.coach` for the coach row) renders a disabled switch + a
|
||||
* server-wide (the module-availability blob, or for the coach row the
|
||||
* operator's Coach switch as the `coach` capability reports it) renders a disabled switch + a
|
||||
* "disabled server-wide" hint — a per-user toggle could not re-enable it.
|
||||
*
|
||||
* The three CORE domains (weight, blood pressure, pulse) render as a
|
||||
@@ -63,7 +63,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ModuleToggleRow } from "@/components/settings/module-toggle-row";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
@@ -118,7 +118,7 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
|
||||
export function ModulesSection() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const flags = useFeatureFlags();
|
||||
const coach = useAiCapability("coach");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const modules = user?.modules ?? {};
|
||||
@@ -234,13 +234,13 @@ export function ModulesSection() {
|
||||
|
||||
// Operator precedence. A per-user toggle can never re-enable a
|
||||
// module the operator turned off server-wide, so the switch goes
|
||||
// disabled + hint. For coach the operator layer is BOTH the
|
||||
// module-availability blob AND the assistant master flag
|
||||
// (`flags.coach`, already master-composed); for cycle and the
|
||||
// owned modules it is the module-availability blob alone.
|
||||
// disabled + hint. For coach the operator layer is the
|
||||
// operator's Coach switch, read from the resolved `coach`
|
||||
// capability (`operator_disabled`, master applied); for cycle
|
||||
// and the owned modules it is the module-availability blob.
|
||||
const operatorAvailable =
|
||||
delegate === "coach"
|
||||
? moduleAvailability[key] !== false && flags.coach
|
||||
? coach.reason !== "operator_disabled"
|
||||
: moduleAvailability[key] !== false;
|
||||
const disabledReason = operatorAvailable
|
||||
? undefined
|
||||
|
||||
@@ -56,6 +56,7 @@ function inputs(
|
||||
dataSourceConnected: false,
|
||||
notificationsConfigured: false,
|
||||
insightsConfigured: false,
|
||||
aiConfigurable: true,
|
||||
dismissedIds: new Set<ChecklistItemId>(),
|
||||
upcomingVisitCount: 0,
|
||||
managedProfileCount: 0,
|
||||
@@ -94,13 +95,22 @@ describe("buildChecklist", () => {
|
||||
it("flags insights done once any provider can serve the user", () => {
|
||||
const off = buildChecklist(inputs({ insightsConfigured: false }));
|
||||
expect(off.find((i) => i.id === "insights")?.done).toBe(false);
|
||||
// `insightsConfigured` is derived from aiAvailable, which is true for a
|
||||
// personal key, a local model, an OAuth sign-in, OR the operator's
|
||||
// shared key — any one flips the row done.
|
||||
// `insightsConfigured` is `ai.provider.configured` from `/api/auth/me`,
|
||||
// true for a personal key, a local model, an OAuth sign-in, OR the
|
||||
// operator's shared key; any one flips the row done.
|
||||
const on = buildChecklist(inputs({ insightsConfigured: true }));
|
||||
expect(on.find((i) => i.id === "insights")?.done).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the AI row out when AI cannot be set up here", () => {
|
||||
// `provider.canConfigure` is false while the operator has AI switched
|
||||
// off and inside a record somebody else owns. HealthLog is complete
|
||||
// without AI, so the list never carries a to-do nobody here can finish.
|
||||
const items = buildChecklist(inputs({ aiConfigurable: false }));
|
||||
expect(items.map((i) => i.id)).not.toContain("insights");
|
||||
expect(items).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("marks profile done when all three fields set", () => {
|
||||
const items = buildChecklist(inputs());
|
||||
expect(items.find((i) => i.id === "profile")?.done).toBe(true);
|
||||
@@ -293,6 +303,7 @@ describe("visibleChecklist + checklistProgress", () => {
|
||||
dataSourceConnected: false,
|
||||
notificationsConfigured: false,
|
||||
insightsConfigured: false,
|
||||
aiConfigurable: true,
|
||||
dismissedIds: new Set(),
|
||||
upcomingVisitCount: 0,
|
||||
managedProfileCount: 0,
|
||||
|
||||
@@ -74,12 +74,20 @@ export interface ChecklistInputs {
|
||||
/**
|
||||
* True iff any AI provider can serve this user — a personal key,
|
||||
* a local model, an OAuth sign-in, OR the operator's shared key.
|
||||
* Derived from `/api/user/ai-provider`'s `aiAvailable`, so the row
|
||||
* self-satisfies the moment insights become reachable (including on a
|
||||
* `ai.provider.configured` from `GET /api/auth/me`, so the row
|
||||
* self-satisfies the moment a provider is set up (including on a
|
||||
* deployment that ships a shared operator key). Presence-only — it
|
||||
* never decrypts a credential or probes liveness.
|
||||
*/
|
||||
insightsConfigured: boolean;
|
||||
/**
|
||||
* Whether AI can be set up from here at all (`ai.provider.canConfigure`):
|
||||
* false while the operator has AI switched off and inside a record
|
||||
* somebody else owns. The AI row exists only when true. HealthLog is
|
||||
* complete without AI, so the list never carries a to-do nobody on this
|
||||
* screen can finish.
|
||||
*/
|
||||
aiConfigurable: boolean;
|
||||
/** Dismissed item ids (per-item localStorage state). */
|
||||
dismissedIds: ReadonlySet<ChecklistItemId>;
|
||||
/**
|
||||
@@ -197,12 +205,16 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] {
|
||||
href: "/settings/notifications",
|
||||
dismissed: inputs.dismissedIds.has("notifications"),
|
||||
},
|
||||
{
|
||||
id: "insights",
|
||||
done: inputs.insightsConfigured,
|
||||
href: "/settings/ai",
|
||||
dismissed: inputs.dismissedIds.has("insights"),
|
||||
},
|
||||
...(inputs.aiConfigurable
|
||||
? [
|
||||
{
|
||||
id: "insights" as const,
|
||||
done: inputs.insightsConfigured,
|
||||
href: "/settings/ai",
|
||||
dismissed: inputs.dismissedIds.has("insights"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
if (!inputs.onboarding) return items;
|
||||
// The visit row exists only for the answer that asked for it; every other
|
||||
|
||||
Reference in new issue
Block a user