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