From 3d85445e50d5bea31fc252bf61d9043ffc09d35b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 11:05:54 +0200 Subject: [PATCH] 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. --- src/components/admin/assistant-section.tsx | 5 +- .../onboarding/__tests__/done-screen.test.tsx | 25 ++ .../getting-started-checklist.test.tsx | 26 +- src/components/onboarding/done-screen.tsx | 286 +++++++++--------- .../onboarding/getting-started-checklist.tsx | 33 +- .../settings/__tests__/ai-section.test.tsx | 17 ++ .../__tests__/coach-memory-while-off.test.tsx | 144 +++++++++ .../settings/__tests__/coach-section.test.tsx | 1 + ...insights-overview-arrange-section.test.tsx | 17 +- .../__tests__/modules-section.test.tsx | 33 +- .../settings/__tests__/sections.test.tsx | 16 + src/components/settings/ai-section.tsx | 26 +- .../settings/ai/ai-consent-card.tsx | 133 +++++++- src/components/settings/ai/auto-read-card.tsx | 22 +- .../settings/ai/operator-off-notice.tsx | 64 ++++ .../settings/ai/runtime-actions-row.tsx | 24 +- src/components/settings/anamnesis-section.tsx | 5 + .../coach-conversations-memory-card.tsx | 138 +++++++++ .../settings/coach-memory-section.tsx | 40 ++- src/components/settings/coach-section.tsx | 13 +- .../insights-overview-arrange-section.tsx | 14 +- src/components/settings/modules-section.tsx | 18 +- .../onboarding/__tests__/checklist.test.ts | 17 +- src/lib/onboarding/checklist.ts | 28 +- 24 files changed, 913 insertions(+), 232 deletions(-) create mode 100644 src/components/settings/__tests__/coach-memory-while-off.test.tsx create mode 100644 src/components/settings/ai/operator-off-notice.tsx create mode 100644 src/components/settings/coach-conversations-memory-card.tsx diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx index 37a657333..6181a04ed 100644 --- a/src/components/admin/assistant-section.tsx +++ b/src/components/admin/assistant-section.tsx @@ -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) => { diff --git a/src/components/onboarding/__tests__/done-screen.test.tsx b/src/components/onboarding/__tests__/done-screen.test.tsx index c03b2d545..2985b6b12 100644 --- a/src/components/onboarding/__tests__/done-screen.test.tsx +++ b/src/components/onboarding/__tests__/done-screen.test.tsx @@ -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(" 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(); diff --git a/src/components/onboarding/__tests__/getting-started-checklist.test.tsx b/src/components/onboarding/__tests__/getting-started-checklist.test.tsx index 401dbe207..5d6f65c2f 100644 --- a/src/components/onboarding/__tests__/getting-started-checklist.test.tsx +++ b/src/components/onboarding/__tests__/getting-started-checklist.test.tsx @@ -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(); @@ -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 = { '["medications"]': [{ id: "m1" }, { id: "m2" }, { id: "m3" }], - '["user","ai-provider"]': { aiAvailable: true }, '["integrations","status"]': { integrations: [{ integration: "whoop", connected: true }], }, @@ -121,13 +132,24 @@ describe(" — 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", () => { diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index af4636436..488ce1165 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -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({ 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. */} -
-
- -
-

+
+

-

- {t("onboarding.ai.panelIntro")} -

-
-
+ + +
+

+ {t("onboarding.ai.panelTitle")} +

+

+ {t("onboarding.ai.panelIntro")} +

+
+ - {sampleOpen ? ( - - ) : ( - - )} - - {sharedKeyNote ? ( -

- {justGranted - ? t("onboarding.ai.offer.granted") - : t("onboarding.ai.sharedKeyNote")} -

- ) : null} - - {offerShared ? ( -
-

- {t("onboarding.ai.offer.title")} -

-

- {t("onboarding.ai.offer.body")} -

+ {sampleOpen ? ( + + ) : ( -
- ) : null} + )} - {sharedUnavailable ? ( -
+ {sharedKeyNote ? ( +

+ {justGranted + ? t("onboarding.ai.offer.granted") + : t("onboarding.ai.sharedKeyNote")} +

+ ) : null} + + {offerShared ? ( +
+

+ {t("onboarding.ai.offer.title")} +

+

+ {t("onboarding.ai.offer.body")} +

+ +
+ ) : null} + + {sharedUnavailable ? ( +
+

+ {t("onboarding.ai.unavailable.title")} +

+

+ {t("onboarding.ai.unavailable.body")} +

+
+ ) : null} + +

- {t("onboarding.ai.unavailable.title")} + {t("onboarding.ai.ladderTitle")}

-

- {t("onboarding.ai.unavailable.body")} -

-
- ) : null} - -
-

- {t("onboarding.ai.ladderTitle")} -

-
    - {/* Local first — the calm, private default — then BYOK, then +
      + {/* 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. */} -
    • -

      - {t("onboarding.ai.ladderLocalTitle")} -

      -

      - {t("onboarding.ai.ladderLocalBody")} -

      -
    • -
    • -

      - {t("onboarding.ai.ladderByokTitle")} -

      -

      - {t("onboarding.ai.ladderByokBody")} -

      -
    • -
    • -

      - {t("onboarding.ai.ladderOauthTitle")} -

      -

      - {t("onboarding.ai.ladderOauthBody")} -

      -
    • -
    -
+
  • +

    + {t("onboarding.ai.ladderLocalTitle")} +

    +

    + {t("onboarding.ai.ladderLocalBody")} +

    +
  • +
  • +

    + {t("onboarding.ai.ladderByokTitle")} +

    +

    + {t("onboarding.ai.ladderByokBody")} +

    +
  • +
  • +

    + {t("onboarding.ai.ladderOauthTitle")} +

    +

    + {t("onboarding.ai.ladderOauthBody")} +

    +
  • + +
    -

    - {t("onboarding.ai.keylessLine")} -

    +

    + {t("onboarding.ai.keylessLine")} +

    - + - - {t("onboarding.ai.setupCta")} - -
    + + {t("onboarding.ai.setupCta")} + + + ) : null}
    + +
    + ) : ( + + )} + {revoke.isError ? ( +

    + {t("settings.ai.consent.error")} +

    + ) : null} + + ); +} diff --git a/src/components/settings/ai/auto-read-card.tsx b/src/components/settings/ai/auto-read-card.tsx index 197598a9d..76b862265 100644 --- a/src/components/settings/ai/auto-read-card.tsx +++ b/src/components/settings/ai/auto-read-card.tsx @@ -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() { + {operatorOff ? ( +

    + {t("settings.ai.operatorOff.autoRead")} +

    + ) : null} + {pendingEnable ? (
    (); + 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 ( + + +

    + {allOff + ? t("settings.ai.operatorOff.allBody") + : t("settings.ai.operatorOff.someBody", { + switches: names.join(", "), + })} +

    +
    + ); +} diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx index ddc043a4f..c0a1ebcce 100644 --- a/src/components/settings/ai/runtime-actions-row.tsx +++ b/src/components/settings/ai/runtime-actions-row.tsx @@ -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(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.
    - {canRegenerate && ( + {showRegenerate && (
    @@ -264,7 +276,7 @@ export function RuntimeActionsRow({ )} {t("settings.ai.testProvider")} - {canRegenerate && ( + {showRegenerate && ( + +
    + {openId === c.id ? : null} + + ))} + + )} + {history.hasNextPage ? ( + + ) : null} + + ); +} diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index 73826576a..67a4141bf 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -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 `` 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 { 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 ( ); } + +/** + * 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 ( + <> + + + + ); +} diff --git a/src/components/settings/coach-section.tsx b/src/components/settings/coach-section.tsx index fca4b52fb..947ef6650 100644 --- a/src/components/settings/coach-section.tsx +++ b/src/components/settings/coach-section.tsx @@ -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() {
    )} - {coachEnabled && } + + + {coachEnabled && }
    diff --git a/src/components/settings/insights-overview-arrange-section.tsx b/src/components/settings/insights-overview-arrange-section.tsx index 1d7a41206..5acb424cb 100644 --- a/src/components/settings/insights-overview-arrange-section.tsx +++ b/src/components/settings/insights-overview-arrange-section.tsx @@ -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(); - 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:`); 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 (
    = { 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 diff --git a/src/lib/onboarding/__tests__/checklist.test.ts b/src/lib/onboarding/__tests__/checklist.test.ts index 06d5cf2bc..ab84a7908 100644 --- a/src/lib/onboarding/__tests__/checklist.test.ts +++ b/src/lib/onboarding/__tests__/checklist.test.ts @@ -56,6 +56,7 @@ function inputs( dataSourceConnected: false, notificationsConfigured: false, insightsConfigured: false, + aiConfigurable: true, dismissedIds: new Set(), 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, diff --git a/src/lib/onboarding/checklist.ts b/src/lib/onboarding/checklist.ts index 20fd24d3c..e0690f7e5 100644 --- a/src/lib/onboarding/checklist.ts +++ b/src/lib/onboarding/checklist.ts @@ -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; /** @@ -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