Merge branch 'feat/ai-web-surfaces' into release/v1.39.0

This commit is contained in:
Marc-André Bombeck committed 2026-09-24 11:37:10 +02:00
commit e0ece41e21
120 files changed
+3851 -2027

No files matched your search

+2 -3
View File
@@ -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) => {
@@ -0,0 +1,55 @@
"use client";
import { ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useGrantDocumentReadingConsent } from "@/hooks/use-grant-document-reading-consent";
import { useTranslations } from "@/lib/i18n/context";
/**
* The consent question, asked where a document is about to be read.
*
* Shown in place of a document-reading action while its capability reports
* `consent_required`: the chain would send the document to a service outside
* this server and no receipt covers that yet. One sentence says what would
* happen, one button grants exactly that (`ai_extraction`), and the action
* appears once `/api/auth/me` confirms it. Withdrawing stays in Settings → AI.
*/
export function DocumentReadingConsentPrompt({
className,
}: {
className?: string;
}) {
const { t } = useTranslations();
const grant = useGrantDocumentReadingConsent();
return (
<div
data-slot="document-reading-consent"
className={
className ??
"border-border flex flex-col items-start gap-2 rounded-lg border border-dashed px-3 py-2.5"
}
>
<p className="text-foreground flex items-start gap-2 text-sm">
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<span>{t("documents.readingConsent.body")}</span>
</p>
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={grant.isPending}
onClick={() => grant.mutate()}
data-slot="document-reading-consent-grant"
>
{t("documents.readingConsent.grant")}
</Button>
{grant.isError ? (
<p role="alert" className="text-destructive text-xs">
{t("documents.readingConsent.error")}
</p>
) : null}
</div>
);
}
@@ -48,7 +48,7 @@ vi.mock("../use-cycle", async () => {
await vi.importActual<typeof import("../use-cycle")>("../use-cycle");
return {
...actual,
useCycleCalendar: () => calendarState.current,
useCycleRingCalendar: () => calendarState.current,
};
});
@@ -0,0 +1,53 @@
/**
* The scores strip gives the cycle ring a cell only when the ring would draw
* a dial. The ring renders nothing without an active cycle, and a cell
* reserved for it (with Strain dropped to make room) sat empty in the row.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { queryKeys } from "@/lib/query-keys";
import { localYmd, useCycleRingHasDial } from "../use-cycle";
function window(): [string, string] {
const from = new Date();
from.setDate(from.getDate() - 90);
const to = new Date();
to.setDate(to.getDate() + 180);
return [localYmd(from), localYmd(to)];
}
function Probe({ enabled }: { enabled: boolean }) {
return <span>{String(useCycleRingHasDial(enabled))}</span>;
}
function hasDial(verdict: unknown, enabled = true): boolean {
const client = new QueryClient({
defaultOptions: { queries: { staleTime: Infinity, retry: 0 } },
});
const [from, to] = window();
client.setQueryData(queryKeys.cycleCalendar(from, to), { verdict });
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<Probe enabled={enabled} />
</QueryClientProvider>,
);
return html === "<span>true</span>";
}
describe("useCycleRingHasDial", () => {
it("is true while a cycle is active today", () => {
expect(hasDial({ phase: "follicular", dayOfCycle: 6 })).toBe(true);
});
it("is false without an active cycle, so no empty cell is reserved", () => {
expect(hasDial({ phase: null, dayOfCycle: null })).toBe(false);
expect(hasDial(null)).toBe(false);
});
it("is false while the cycle surface is hidden", () => {
expect(hasDial({ phase: "luteal", dayOfCycle: 20 }, false)).toBe(false);
});
});
+3 -13
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useState } from "react";
import Link from "next/link";
import { Droplets } from "lucide-react";
@@ -8,7 +8,7 @@ import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { CycleRing } from "./cycle-ring";
import { PHASE_HUE } from "./phase-tokens";
import { localYmd, useCycleCalendar } from "./use-cycle";
import { useCycleRingCalendar } from "./use-cycle";
/**
* v1.15.3 — the compact cycle ring as a WELLNESS-STRIP element.
@@ -36,22 +36,12 @@ import { localYmd, useCycleCalendar } from "./use-cycle";
* strip (no gap, no placeholder).
*/
/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */
function shiftToday(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return localYmd(d);
}
export function CycleRingTile({ className }: { className?: string }) {
const { t } = useTranslations();
// Mirror the cycle-view + summary-card window so the cache key is shared —
// no extra request when the user opens the cycle page in the same session.
const from = useMemo(() => shiftToday(-90), []);
const to = useMemo(() => shiftToday(180), []);
const calendar = useCycleCalendar(from, to);
const calendar = useCycleRingCalendar();
// The server already resolved the ring: cycle day, phase, and the arc
// proportions (including the profile-derived idealized ring a low-data
// tracker gets). The tile renders it.
+30
View File
@@ -9,6 +9,7 @@
* wheel, predictions panel, and history repaint in lockstep after a quick
* log.
*/
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -111,6 +112,35 @@ export function useCycleCalendar(from: string, to: string, enabled = true) {
});
}
/** YYYY-MM-DD for `days` from today in the local zone. */
function localYmdFromToday(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return localYmd(d);
}
/**
* The calendar window the cycle ring reads: the cycle page's own window, so
* the ring, the page and the summary card share one cache entry.
*/
export function useCycleRingCalendar(enabled = true) {
const from = useMemo(() => localYmdFromToday(-90), []);
const to = useMemo(() => localYmdFromToday(180), []);
return useCycleCalendar(from, to, enabled);
}
/**
* Whether the cycle ring would draw a dial today. The wellness strip asks
* this before it gives the ring a cell (and takes Strain's place for it): the
* ring renders nothing without an active cycle, and a cell reserved for it
* would sit empty in the row.
*/
export function useCycleRingHasDial(enabled: boolean): boolean {
const calendar = useCycleRingCalendar(enabled);
const verdict = calendar.data?.verdict;
return enabled && verdict?.phase != null && verdict.dayOfCycle != null;
}
export function useCycleHistory(limit = 24) {
return useQuery({
queryKey: queryKeys.cycleHistory(limit),
@@ -0,0 +1,43 @@
/**
* A refused document read names its reason. Before the capability refusals
* had codes of their own, a missing consent, the operator's switch and a
* missing provider all fell through to the generic "couldn't read" line, and
* the one thing the reader could fix (the consent) was never said.
*/
import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/api/api-fetch";
import { documentAiErrorKey } from "../use-document-assist";
const refusal = (errorCode: string, status = 403) =>
new ApiError("refused", status, { errorCode });
describe("documentAiErrorKey", () => {
it("maps each capability refusal to its own sentence", () => {
expect(documentAiErrorKey(refusal("consent.ai.required"))).toBe(
"documents.assist.errorConsent",
);
expect(documentAiErrorKey(refusal("assistant.disabled.documentAi"))).toBe(
"documents.assist.errorUnavailable",
);
expect(documentAiErrorKey(refusal("assistant.disabled.enabled"))).toBe(
"documents.assist.errorUnavailable",
);
expect(documentAiErrorKey(refusal("ai.record.notPermitted"))).toBe(
"documents.assist.errorUnavailable",
);
expect(documentAiErrorKey(refusal("ai.provider.none", 422))).toBe(
"documents.assist.errorProvider",
);
});
it("keeps the existing route codes", () => {
expect(
documentAiErrorKey(refusal("documents.inbound.budgetExceeded", 429)),
).toBe("documents.assist.errorBudget");
expect(documentAiErrorKey(refusal("something.else", 500))).toBe(
"documents.assist.errorGeneric",
);
});
});
@@ -62,6 +62,8 @@ import {
apiPost,
} from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-consent-prompt";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import {
@@ -389,8 +391,15 @@ export function DocumentDetailSheet({
// not, and both of these routes stay on the caller's own authentication. A
// delegate would fire two queries that must 403 in order to compute an
// affordance every branch below already withholds on `canManageDocuments`.
// Reading a document with AI follows the `documentAi` capability on
// `/api/auth/me` (the operator's "Reading documents" switch, the provider,
// the document-reading consent). Unavailable, the actions are simply not
// offered and the probe never fires; a missing consent is asked for in
// place instead.
const documentAi = useAiCapability("documentAi");
const coach = useAiCapability("coach");
const capability = useDocumentAiCapability(
canManageDocuments && open && documentId !== null,
canManageDocuments && open && documentId !== null && documentAi.available,
);
// When auto-read is ON, reading happens automatically on upload — the manual
// per-document AI action row is redundant and collapses away.
@@ -558,9 +567,12 @@ export function DocumentDetailSheet({
// The affordance gate falls back to the capability probe when the sheet is
// rendered without the usage-derived props (deep link before usage loads).
const aiEnabled = assistAvailable ?? capability.data?.available ?? false;
const aiEnabled =
documentAi.available &&
(assistAvailable ?? capability.data?.available ?? false);
const indexEnabled =
contentIndexEnabled ?? capability.data?.available ?? false;
documentAi.available &&
(contentIndexEnabled ?? capability.data?.available ?? false);
const autoReadEnabled = autoRead.data?.documentsAutoAiRead ?? false;
const aiMode = capability.data?.mode === "text" ? "text" : "vision";
const aiTarget: DocumentAiTarget | null = doc
@@ -742,7 +754,7 @@ export function DocumentDetailSheet({
inside somebody else's record (the shell mounts no drawer
there), and `/documents` is a shared-record destination, so
this is the Coach button a delegate actually meets. */}
{aiEnabled && launch ? (
{aiEnabled && coach.available && launch ? (
// v1.28.52 (Documents R3) — "Ask the Coach" opens the REAL
// coach conversation scoped to this document in the SIDE
// DRAWER (the maximize control there expands it to the full
@@ -856,7 +868,11 @@ export function DocumentDetailSheet({
onGenerate={generateStoredSummary}
/>
{canManageDocuments && (
{canManageDocuments && documentAi.reason === "consent_required" ? (
<DocumentReadingConsentPrompt />
) : null}
{canManageDocuments && documentAi.available && (
<DocumentAiSection
aiEnabled={aiEnabled}
autoReadEnabled={autoReadEnabled}
@@ -200,8 +200,19 @@ export function documentAiErrorKey(err: unknown): string {
return "documents.assist.errorProvider";
case "documents.inbound.notIndexed":
return "documents.assist.errorNotIndexed";
case "consent.ai.required":
return "documents.assist.errorConsent";
case "ai.provider.none":
return "documents.assist.errorProvider";
case "ai.record.notPermitted":
case "module.disabled":
return "documents.assist.errorUnavailable";
default:
return "documents.assist.errorGeneric";
// The operator switched reading off (`assistant.disabled.documentAi`,
// or the master `assistant.disabled.enabled`).
return code.startsWith("assistant.disabled.")
? "documents.assist.errorUnavailable"
: "documents.assist.errorGeneric";
}
}
return "documents.assist.errorGeneric";
@@ -0,0 +1,112 @@
/**
* Every Coach entry point follows one answer: the `coach` capability on
* `/api/auth/me`.
*
* The launchers used to AND the operator's switch with the person's Hide
* Coach, each on its own, and none of them knew about a missing provider or
* a missing consent, so the floating button and the "Ask the Coach" pills
* stayed on screen and opened a Coach that could only answer with an error.
* Now each surface paints only while the capability is available, and is
* gone (no DOM trace, no inert control) for every reason it is not.
*/
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import {
AI_UNAVAILABLE_REASONS,
type AiCapabilityKey,
type AiCapabilityState,
} from "@/lib/ai/capabilities/types";
import {
AI_AVAILABLE,
aiUnavailable,
} from "@/__tests__/helpers/ai-capability-fixtures";
const coachState = vi.hoisted(() => ({
current: null as AiCapabilityState | null,
asked: [] as string[],
}));
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: (key: AiCapabilityKey) => {
coachState.asked.push(key);
return coachState.current;
},
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/insights",
}));
import { AskCoachAction } from "../ask-coach-action";
import { CoachLaunchButton } from "../coach-launch-button";
import { LayoutCoachFab } from "../layout-coach-fab";
import { LayoutCoachMount } from "../layout-coach-mount";
function render(node: ReactNode): string {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<CoachLaunchProvider>{node}</CoachLaunchProvider>
</I18nProvider>
</QueryClientProvider>,
);
}
interface Surface {
name: string;
mount: () => ReactNode;
/** What proves it painted; empty for surfaces that SSR to nothing. */
proof: string;
}
const SURFACES: Surface[] = [
{
name: "CoachLaunchButton",
mount: () => <CoachLaunchButton />,
proof: 'data-slot="coach-launch-inline"',
},
{
name: "AskCoachAction",
mount: () => <AskCoachAction question="Why?" />,
proof: 'data-slot="ask-coach-action"',
},
{
name: "LayoutCoachFab",
mount: () => <LayoutCoachFab />,
proof: 'data-slot="coach-fab"',
},
// The drawer is lazy (next/dynamic) and SSRs to nothing either way; the
// capability read is what the test pins for it.
{ name: "LayoutCoachMount", mount: () => <LayoutCoachMount />, proof: "" },
];
beforeEach(() => {
coachState.current = AI_AVAILABLE;
coachState.asked = [];
vi.spyOn(console, "error").mockImplementation(() => undefined);
});
describe("Coach entry points follow the coach capability", () => {
for (const surface of SURFACES) {
it(`${surface.name} paints while the Coach is available`, () => {
const html = render(surface.mount());
if (surface.proof) expect(html).toContain(surface.proof);
expect(coachState.asked).toContain("coach");
});
for (const reason of AI_UNAVAILABLE_REASONS) {
it(`${surface.name} is gone when the Coach is unavailable (${reason})`, () => {
coachState.current = aiUnavailable(reason);
const html = render(surface.mount());
if (surface.proof) expect(html).not.toContain(surface.proof);
expect(html).not.toMatch(/data-slot="(coach-|ask-coach)/);
expect(coachState.asked).toContain("coach");
});
}
}
});
@@ -268,28 +268,18 @@ describe("<DailyBriefing>", () => {
expect(html).not.toContain('data-slot="daily-briefing-meta-slot"');
});
// v1.15.20 — no-provider state: the regenerate CTA would 422 forever,
// so the card points at Settings → AI instead.
it("renders the connect-provider hint with a settings link when noProvider", () => {
// v1.39 (J1) — the card has no "no provider" state of its own any more.
// It only mounts while the briefing capability is available, which
// implies a provider; a missing provider is said once by the setup hint on
// the overview. The empty state therefore always offers the regenerate CTA.
it("never renders a connect-provider state; the empty state offers regenerate", () => {
const html = render(
<DailyBriefing briefing={null} onRegenerate={() => {}} noProvider />,
<DailyBriefing briefing={null} onRegenerate={() => {}} />,
);
expect(html).toContain('data-slot="daily-briefing-no-provider"');
expect(html).toContain("No AI provider connected");
expect(html).toMatch(/href="\/settings\/ai"/);
// The futile regenerate CTA must NOT render in this state.
expect(html).not.toContain('data-slot="daily-briefing-empty-cta"');
});
it("renders the German connect-provider hint under the de locale", () => {
const html = render(<DailyBriefing briefing={null} noProvider />, "de");
expect(html).toContain("Kein KI-Anbieter verbunden");
});
it("prefers the briefing content over the no-provider hint when both exist", () => {
const html = render(<DailyBriefing briefing={baseBriefing} noProvider />);
expect(html).toContain('data-slot="daily-briefing-findings"');
expect(html).not.toContain('data-slot="daily-briefing-no-provider"');
expect(html).not.toContain("daily-briefing-no-provider");
expect(html).not.toContain("daily-briefing-stale-no-provider");
expect(html).not.toMatch(/href="\/settings\/ai"/);
expect(html).toContain('data-slot="daily-briefing-empty-cta"');
});
// v1.25 — generation-failed affordances. The briefing keeps its last good
@@ -316,19 +306,6 @@ describe("<DailyBriefing>", () => {
expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"');
});
it("suppresses the refresh-failed hint when no provider is connected", () => {
const html = render(
<DailyBriefing
briefing={baseBriefing}
onRegenerate={() => {}}
generationFailed
noProviderStale
/>,
);
expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"');
expect(html).toContain('data-slot="daily-briefing-stale-no-provider"');
});
it("renders the 'couldn't generate' empty state with a retry CTA when failed and empty", () => {
const html = render(
<DailyBriefing
@@ -16,6 +16,15 @@ import type { InsightStatusData } from "@/hooks/use-insight-status";
* so SSR renders its stub — enough to verify the assessment slot.
*/
// AI surfaces paint only while their capability is available; these tests
// cover the painted surface, so every capability reads available here.
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = () => <div data-slot="healthkit-chart-stub" />;
@@ -0,0 +1,86 @@
/**
* A status note is model-written text: the card paints only while the
* `statusText` capability is available and is gone, never an error or a
* "connect a provider" hint, for every reason it is not. The score anatomy's
* assessment is composed from the numbers (`aiAuthored={false}`) and shows
* whatever the AI state.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import {
AI_UNAVAILABLE_REASONS,
type AiCapabilityKey,
type AiCapabilityState,
} from "@/lib/ai/capabilities/types";
import {
AI_AVAILABLE,
aiUnavailable,
} from "@/__tests__/helpers/ai-capability-fixtures";
const capability = vi.hoisted(() => ({
current: null as AiCapabilityState | null,
asked: [] as AiCapabilityKey[],
}));
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: (key: AiCapabilityKey) => {
capability.asked.push(key);
return capability.current;
},
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { timezone: "Europe/Berlin" } }),
}));
import { InsightStatusCard } from "../insight-status-card";
const baseProps = {
title: "Pulse",
icon: null,
text: "Your pulse is stable.",
hasProvider: true,
updatedAt: null,
};
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
beforeEach(() => {
capability.current = AI_AVAILABLE;
capability.asked = [];
});
describe("<InsightStatusCard> — statusText capability", () => {
it("renders the note while status notes are available", () => {
const html = render(<InsightStatusCard {...baseProps} />);
expect(html).toContain("Your pulse is stable");
expect(capability.asked).toContain("statusText");
});
for (const reason of AI_UNAVAILABLE_REASONS) {
it(`renders nothing when status notes are unavailable (${reason})`, () => {
capability.current = aiUnavailable(reason);
expect(render(<InsightStatusCard {...baseProps} />)).toBe("");
});
}
it("never renders a connect-a-provider state", () => {
const html = render(
<InsightStatusCard {...baseProps} text={null} hasProvider={false} />,
);
expect(html).not.toContain("/settings/ai");
expect(html).not.toContain("insight-status-no-provider-cta");
});
it("keeps a composed assessment visible whatever the AI state", () => {
capability.current = aiUnavailable("operator_disabled");
const html = render(
<InsightStatusCard {...baseProps} aiAuthored={false} />,
);
expect(html).toContain("Your pulse is stable");
});
});
@@ -1,86 +0,0 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { InsightStatusCard } from "../insight-status-card";
/**
* v1.4.31 — the per-metric `<InsightStatusCard>` returns `null` when
* the operator has disabled the `insightStatus` surface. Renders
* normally otherwise.
*/
function wrap(node: React.ReactNode, client: QueryClient) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
const baseProps = {
title: "Pulse",
icon: null,
text: "Your pulse is stable.",
hasProvider: true,
updatedAt: null,
};
function makeClient(flags: {
enabled: boolean;
coach: boolean;
briefing: boolean;
insightStatus: boolean;
documentAi: boolean;
}): QueryClient {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
client.setQueryData(["feature-flags"], { assistant: flags });
return client;
}
describe("<InsightStatusCard> — assistant.insightStatus gate", () => {
it("renders the card when the flag is on", () => {
const html = wrap(
<InsightStatusCard {...baseProps} />,
makeClient({
enabled: true,
coach: true,
briefing: true,
insightStatus: true,
documentAi: true,
}),
);
expect(html).toContain("Your pulse is stable");
});
it("returns null when the operator disables the sub-flag", () => {
const html = wrap(
<InsightStatusCard {...baseProps} />,
makeClient({
enabled: true,
coach: true,
briefing: true,
insightStatus: false,
documentAi: true,
}),
);
expect(html).toBe("");
});
it("returns null when the master flag is off", () => {
const html = wrap(
<InsightStatusCard {...baseProps} />,
makeClient({
enabled: false,
coach: false,
briefing: false,
insightStatus: false,
documentAi: false,
}),
);
expect(html).toBe("");
});
});
@@ -14,6 +14,17 @@ vi.mock("@/hooks/use-auth", () => ({
}),
}));
// The card paints only while the `statusText` capability is available; these
// tests are about the painted card, so the capability reads available.
// `insight-status-card-capability-gate.test.tsx` owns the gate itself.
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
/**
* v1.4.27 — F16 regression cover. The per-metric status text comes
* out of the AI provider with a literal `metric:<TYPE>` token (the
@@ -82,17 +93,6 @@ describe("<InsightStatusCard>", () => {
expect(html).toContain("Pulse stayed inside the band.");
});
it("renders the no-provider setup state when no terminal text exists", () => {
const html = render(
<InsightStatusCard {...baseProps} hasProvider={false} text={null} />,
);
// v1.18.6 — the no-provider tile is a guided-setup explainer + a
// Settings link, not a bare "unavailable" line.
expect(html).toContain("Connect an AI provider");
expect(html).toContain("Open AI settings");
expect(html).toContain("/settings/ai");
});
it("renders a screened terminal fallback even when its envelope has no provider", () => {
const html = render(
<InsightStatusCard
@@ -159,16 +159,13 @@ describe("<InsightStatusCard>", () => {
expect(html).not.toContain("animate-spin");
});
it("keeps loading, preparing, no-provider, empty, and populated states distinct with stable compact geometry", () => {
it("keeps loading, preparing, empty, and populated states distinct with stable compact geometry", () => {
const loading = render(
<InsightStatusCard {...baseProps} text={null} loading />,
);
const preparing = render(
<InsightStatusCard {...baseProps} text={null} preparing />,
);
const noProvider = render(
<InsightStatusCard {...baseProps} hasProvider={false} text={null} />,
);
const empty = render(<InsightStatusCard {...baseProps} text={null} />);
const populated = render(
<InsightStatusCard
@@ -179,11 +176,10 @@ describe("<InsightStatusCard>", () => {
expect(loading).toContain('data-testid="insight-status-card-loading"');
expect(preparing).toContain('data-testid="insight-status-card-preparing"');
expect(noProvider).toContain('data-slot="insight-status-no-provider-cta"');
expect(empty).toContain("No assessment yet.");
expect(populated).toContain('data-slot="insight-assessment"');
for (const html of [loading, preparing, noProvider, empty]) {
for (const html of [loading, preparing, empty]) {
expect(html).toContain("gap-2");
expect(html).toContain("py-3");
expect(html).toContain("md:py-4");
@@ -5,6 +5,15 @@ import { renderToStaticMarkup } from "react-dom/server";
// navigation hooks the FAB uses. The pathname is mutable so the
// hide-on-coach-page branch can be exercised.
let mockPathname = "/insights";
// AI surfaces paint only while their capability is available; these tests
// cover the painted surface, so every capability reads available here.
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => mockPathname,
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { useAiProviderState } from "@/hooks/use-ai-capability";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
/**
* The one place HealthLog mentions that AI could be set up.
*
* HealthLog works without AI, so a missing provider is not a problem to
* report on every card. The per-card "connect an AI provider" states are
* gone; this calm hint replaces them, on the Insights overview and in
* Settings → AI, and nowhere else.
*
* It shows only while no provider is configured AND the person could set one
* up here (`provider.canConfigure`: false inside somebody else's record and
* while the operator has AI switched off, where the hint would point at a
* setting nobody on this screen can change). Dismissing it is remembered per
* viewer in this browser; the hint stays gone on both surfaces.
*/
const AI_SETUP_HINT_STORAGE_PREFIX = "healthlog.ai-setup-hint.dismissed";
function storageKey(viewerId: string): string {
return `${AI_SETUP_HINT_STORAGE_PREFIX}.${viewerId}`;
}
function readDismissed(viewerId: string): boolean {
try {
return window.localStorage.getItem(storageKey(viewerId)) === "1";
} catch {
return false;
}
}
export function AiSetupHint({
surface = "insights",
}: {
/**
* `insights`: a tile on the overview with a link to Settings → AI.
* `settings`: a Settings card on Settings → AI itself, with no link.
*/
surface?: "insights" | "settings";
}) {
const { t } = useTranslations();
const { user } = useAuth();
const provider = useAiProviderState();
// Storage is browser-only: nothing renders before hydration, so the server
// HTML and the first client render agree.
const mounted = useMounted();
const [dismissedNow, setDismissedNow] = useState(false);
const viewerId = user?.id ?? null;
if (!mounted || viewerId === null) return null;
if (provider.configured || !provider.canConfigure) return null;
if (dismissedNow || readDismissed(viewerId)) return null;
const dismiss = () => {
setDismissedNow(true);
try {
window.localStorage.setItem(storageKey(viewerId), "1");
} catch {
// Storage unavailable (private mode): hidden for this visit only.
}
};
if (surface === "settings") {
return (
<SettingsCard data-slot="ai-setup-hint">
<SettingsCardHeader
icon={Sparkles}
title={t("insights.aiSetupHint.title")}
/>
<p className="text-foreground text-sm">
{t("insights.aiSetupHint.settingsBody")}
</p>
<SettingsCardActions>
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={dismiss}
data-slot="ai-setup-hint-dismiss"
>
{t("insights.aiSetupHint.dismiss")}
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
return (
<Card data-slot="ai-setup-hint" className="gap-2 py-3 md:py-4">
<CardHeader>
<TileHeader
icon={Sparkles}
title={t("insights.aiSetupHint.title")}
right={
<Button
type="button"
variant="ghost"
size="icon"
onClick={dismiss}
aria-label={t("insights.aiSetupHint.dismiss")}
title={t("insights.aiSetupHint.dismiss")}
data-slot="ai-setup-hint-dismiss"
className="-my-2 shrink-0"
>
<X className="size-4" aria-hidden="true" />
</Button>
}
/>
</CardHeader>
<CardContent className="flex flex-col items-start gap-3">
<p className="text-foreground text-sm">
{t("insights.aiSetupHint.body")}
</p>
<Button
asChild
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
>
<Link href="/settings/ai" data-slot="ai-setup-hint-link">
{t("insights.aiSetupHint.action")}
</Link>
</Button>
</CardContent>
</Card>
);
}
+7 -12
View File
@@ -15,8 +15,7 @@ import {
useCoachLaunch,
type CoachLaunchScope,
} from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { useAiCapability } from "@/hooks/use-ai-capability";
/**
* v1.21.0 (C4 H2) — discreet "Ask the Coach about this" affordance for
@@ -71,15 +70,13 @@ export function AskCoachAction({
}: AskCoachActionProps) {
const { t } = useTranslations();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
const coach = useAiCapability("coach");
// Same gate posture as <CoachLaunchButton> / <LayoutCoachFab>: render
// nothing unless the provider is mounted, the operator flag is on, and
// the user has not opted out.
// nothing unless the launch provider is mounted and the `coach`
// capability is available.
if (!launch) return null;
if (!flags.coach) return null;
if (disableCoach) return null;
if (!coach.available) return null;
const accessibleLabel = label ?? t("insights.coach.askAboutThis");
@@ -125,12 +122,10 @@ export function AskCoachIconButton({
}: AskCoachActionProps) {
const { t } = useTranslations();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
const coach = useAiCapability("coach");
if (!launch) return null;
if (!flags.coach) return null;
if (disableCoach) return null;
if (!coach.available) return null;
const accessibleLabel = label ?? t("insights.coach.askAboutThis");
@@ -7,8 +7,7 @@ import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { useAiCapability } from "@/hooks/use-ai-capability";
/**
* Inline desktop Coach launch button.
@@ -78,20 +77,16 @@ export function CoachLaunchButton({
}: CoachLaunchButtonProps) {
const { t } = useTranslations();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
const coach = useAiCapability("coach");
if (!launch) {
// The button only makes sense beneath the provider. Render nothing
// so the sub-page doesn't paint a dead control.
return null;
}
// v1.4.31 — operator can hide the Coach surface app-wide.
if (!flags.coach) return null;
// v1.4.47 W3 — per-user opt-out is a peer gate to the operator's
// flag; either being off hides the pill entirely. See
// Hidden while the `coach` capability is unavailable for any reason. See
// `<LayoutCoachFab>` for the matching FAB gate.
if (disableCoach) return null;
if (!coach.available) return null;
const accessibleLabel = label ?? t("insights.heroActionAskCoach");
@@ -734,6 +734,33 @@ describe("errorCodeToI18nKey", () => {
);
});
// v1.39 — a refused turn names its reason. The capability refusals carry
// their code in `meta.errorCode`; each maps to copy that says what is off
// and who can change it, never the generic "provider unavailable".
it("maps each AI capability refusal to its own copy", () => {
expect(errorCodeToI18nKey("consent.ai.required")).toBe(
"insights.coach.errorConsent",
);
expect(errorCodeToI18nKey("ai.record.notPermitted")).toBe(
"insights.coach.errorNotPermitted",
);
expect(errorCodeToI18nKey("ai.unavailable")).toBe(
"insights.coach.errorUnavailable",
);
expect(errorCodeToI18nKey("ai.provider.none")).toBe(
"insights.coach.errorNoProvider",
);
expect(errorCodeToI18nKey("assistant.disabled.coach")).toBe(
"insights.coach.errorOperatorOff",
);
expect(errorCodeToI18nKey("assistant.disabled.enabled")).toBe(
"insights.coach.errorOperatorOff",
);
expect(errorCodeToI18nKey("module.disabled")).toBe(
"insights.coach.errorCoachOff",
);
});
it("forward-compats unknown codes with the namespaced key", () => {
expect(errorCodeToI18nKey("errorProvider")).toBe(
"insights.coach.errorProvider",
@@ -99,7 +99,23 @@ export function errorCodeToI18nKey(code: string): string {
case "coach.provider.empty":
case "coach.stream":
return "insights.coach.errorProvider";
// v1.39 — the capability refusals (`meta.errorCode` on a 403/422/503
// before the stream opens). Each says what is off and who can change it.
case "consent.ai.required":
return "insights.coach.errorConsent";
case "ai.record.notPermitted":
return "insights.coach.errorNotPermitted";
case "ai.unavailable":
return "insights.coach.errorUnavailable";
case "ai.provider.none":
return "insights.coach.errorNoProvider";
case "module.disabled":
return "insights.coach.errorCoachOff";
default:
// The operator switched the Coach (or all AI) off on this server.
if (code.startsWith("assistant.disabled.")) {
return "insights.coach.errorOperatorOff";
}
// Forward-compat: try `insights.coach.<code>` for codes that
// ship their own translation (e.g. legacy `errorProvider`).
return `insights.coach.${code}`;
@@ -756,7 +772,8 @@ function ChatBubbleImpl({
setup gap, not a transient failure: surface a direct link to
Settings → AI so the Coach guides the user into BYOK / local
setup instead of inviting an endless retry. */}
{errorCode === "coach.provider.none" && (
{(errorCode === "coach.provider.none" ||
errorCode === "ai.provider.none") && (
<Link
href="/settings/ai"
data-slot="coach-no-provider-cta"
@@ -0,0 +1,51 @@
"use client";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachConversation } from "./use-coach";
/**
* One stored conversation, read back as plain text while the Coach itself is
* unavailable. The messages render as React text children, like every other
* Coach surface.
*/
export function ConversationTranscript({ id }: { id: string }) {
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useCoachConversation(id);
if (isLoading) {
return (
<p className="text-muted-foreground px-3 pb-3 text-xs">
{t("common.loading")}
</p>
);
}
if (isError || !data) {
return (
<QueryErrorRow
className="mx-3 mb-3"
onRetry={() => void refetch()}
slot="coach-conversations-transcript-error"
/>
);
}
return (
<ol
data-slot="coach-conversations-transcript"
className="flex flex-col gap-3 px-3 pb-3"
>
{data.messages.map((message) => (
<li key={message.id} className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs font-medium">
{message.role === "user"
? t("insights.coach.transcriptYou")
: t("insights.coach.transcriptCoach")}
</span>
<p className="text-foreground text-sm whitespace-pre-wrap">
{message.content}
</p>
</li>
))}
</ol>
);
}
@@ -950,8 +950,13 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
try {
const envelope = (await response.clone().json()) as {
error?: unknown;
meta?: { errorCode?: unknown } | null;
};
if (typeof envelope?.error === "string") {
// v1.39 — a capability refusal carries its code in
// `meta.errorCode` and a sentence in `error`; the code wins.
if (typeof envelope?.meta?.errorCode === "string") {
structured = envelope.meta.errorCode;
} else if (typeof envelope?.error === "string") {
structured = envelope.error;
}
} catch {
@@ -5,6 +5,7 @@ import { AlertTriangle, Activity } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/ui/section-heading";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
@@ -172,11 +173,32 @@ export function CoincidentDeviationCard({
className,
}: CoincidentDeviationCardProps) {
const { t } = useTranslations();
const { data } = useDerivedMetric<CoincidentDeviationValue>(
const { data, isError, refetch } = useDerivedMetric<CoincidentDeviationValue>(
COINCIDENT_METRIC,
{ enabled },
);
// A failed read says so with a retry. Without this branch the card sat on
// its skeleton forever, which read as "still loading" rather than failed.
if (isError && !data) {
return (
<section
data-slot="coincident-deviation-section"
aria-label={t("insights.derived.coincident.cardTitle")}
className={cn("space-y-3", className)}
>
<SectionHeading
icon={Activity}
title={t("insights.derived.coincident.cardTitle")}
/>
<QueryErrorRow
slot="coincident-deviation-error"
onRetry={() => void refetch()}
/>
</section>
);
}
// CLS-safe placeholder while the single read is in flight.
if (!data) {
return <CardSkeleton className={className} />;
+4 -65
View File
@@ -107,21 +107,6 @@ interface DailyBriefingProps {
onRegenerate?: () => void;
/** Disables the regenerate CTA while a generation is in flight. */
regenerating?: boolean;
/**
* v1.15.20 — no AI provider is configured anywhere, so generating is
* futile. The empty state swaps the regenerate CTA for a quiet hint
* linking to Settings → AI instead of an eternal "preparing" loop.
*/
noProvider?: boolean;
/**
* v1.18.9 (#4) — no AI provider configured WHILE a (stale) cached
* briefing is still shown. The read path serves the last good briefing
* regardless of provider state, so a provider-less account keeps seeing
* a days-old briefing. When true, the footer pairs the honest relative
* age with a discreet "this can't refresh — connect a provider" hint so
* the staleness reads as intentional, not as a live report.
*/
noProviderStale?: boolean;
/**
* v1.25 — the most recent generation attempt failed (provider timeout /
* error). The briefing keeps its last good text, so this never blanks a
@@ -365,8 +350,6 @@ export function DailyBriefing({
loading = false,
onRegenerate,
regenerating = false,
noProvider = false,
noProviderStale = false,
generationFailed = false,
generationFailureClass = null,
omittedReason = null,
@@ -482,7 +465,7 @@ export function DailyBriefing({
</div>
</div>
)}
{(updatedAt || noProviderStale || generationFailed) && (
{(updatedAt || generationFailed) && (
<div className="border-border/60 space-y-1.5 border-t pt-3">
{updatedAt && (
<p
@@ -498,33 +481,12 @@ export function DailyBriefing({
)}
</p>
)}
{/* v1.18.9 (#4) — a stale briefing that can never refresh
because no AI provider is connected. State that plainly
and point at Settings → AI, so the days-old read is
understood as held, not presented as current. */}
{noProviderStale && (
<p
data-slot="daily-briefing-stale-no-provider"
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
>
<span>
{t("insights.dailyBriefing.staleNoProviderHint")}
</span>
<Link
href="/settings/ai"
data-slot="daily-briefing-stale-no-provider-link"
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
>
{t("insights.dailyBriefing.noProviderAction")}
</Link>
</p>
)}
{/* v1.25 — the held briefing is shown, but the last refresh
attempt failed (provider timeout / error). State it plainly
and offer a retry so the staleness reads as held, not
current. Suppressed when no provider is configured (that
hint owns the footer) — a retry would be futile there. */}
{generationFailed && !noProviderStale && (
current. The card only mounts while the briefing
capability is available, so a provider exists. */}
{generationFailed && (
<p
data-slot="daily-briefing-refresh-failed"
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
@@ -546,29 +508,6 @@ export function DailyBriefing({
</div>
)}
</div>
) : noProvider ? (
// v1.15.20 — no provider configured anywhere: a regenerate CTA
// would 422 forever, so point at Settings → AI instead.
<EmptyState
data-slot="daily-briefing-no-provider"
variant="plain"
icon={<Sparkles className="size-5" />}
title={t("insights.dailyBriefing.noProviderTitle")}
description={t("insights.dailyBriefing.noProviderDescription")}
action={
<Button
type="button"
size="sm"
variant="outline"
asChild
data-slot="daily-briefing-no-provider-cta"
>
<Link href="/settings/ai">
{t("insights.dailyBriefing.noProviderAction")}
</Link>
</Button>
}
/>
) : (
<EmptyState
data-slot="daily-briefing-empty"
@@ -5,6 +5,7 @@ import { Sparkles } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useDerivedMetric } from "./use-derived-metric";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import type {
ReadinessValue,
@@ -161,8 +162,21 @@ export function CompositeScoreAnatomy({
</>
);
if (query.isError) {
// A failed read is not "insufficient data": the score may well exist.
// Say it failed and offer a retry instead of an empty anatomy.
return (
<div className={className}>
<QueryErrorRow
slot="composite-anatomy-error"
onRetry={() => void query.refetch()}
/>
</div>
);
}
if (!data) {
// Network/abort fallback — render the insufficient state honestly.
// Settled without a payload — render the insufficient state honestly.
return (
<ScoreAnatomyView
title={title}
@@ -276,6 +290,9 @@ export function CompositeScoreAnatomy({
icon={<Sparkles className="h-5 w-5" />}
text={assessment.text}
hasProvider
// Composed from the numbers; the server swaps in model text only
// while `statusText` is available, so the card shows either way.
aiAuthored={false}
updatedAt={assessment.updatedAt}
// The outbound edge. Same shared opener + auto-send hand-off the
// metric pages use, so the answer lands directly instead of only
-35
View File
@@ -1,6 +1,5 @@
"use client";
import Link from "next/link";
import { Sparkles } from "lucide-react";
import {
useTranslations,
@@ -65,15 +64,6 @@ interface HeroStripProps {
* Defaults to `new Date()`. Production callers omit this.
*/
now?: Date;
/**
* v1.18.9 (#4) — true when the hero subtitle is drawn from a cached
* briefing that can never refresh because no AI provider is connected.
* The meta row then appends a discreet "connect a provider" link beside
* the "Generated <relative>" age, so the score-area context reads as
* honestly held rather than current. Consistent with the DailyBriefing
* footer hint below.
*/
noProviderStale?: boolean;
/**
* The Health Score report. Non-null paints the right column; null with
* `scorePending` paints the reserve; null without it keeps the band
@@ -117,7 +107,6 @@ export function HeroStrip({
updatedAt,
userName,
now,
noProviderStale = false,
healthScore = null,
scorePending = false,
tension = null,
@@ -222,30 +211,6 @@ export function HeroStrip({
</span>
</>
)}
{/* v1.18.9 (#4) — the subtitle is a cached briefing that can
never refresh (no AI provider). Append a discreet hint +
Settings → AI link so the relative age above reads as
intentionally held, consistent with the briefing card. */}
{noProviderStale && (
<>
<span aria-hidden="true" className="opacity-50">
·
</span>
<span
data-slot="insights-hero-strip-stale-no-provider"
className="inline-flex flex-wrap items-center gap-x-1.5"
>
<span>{t("insights.dailyBriefing.staleNoProviderHint")}</span>
<Link
href="/settings/ai"
data-slot="insights-hero-strip-stale-no-provider-link"
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
>
{t("insights.dailyBriefing.noProviderAction")}
</Link>
</span>
</>
)}
</div>
</div>
+16 -33
View File
@@ -1,7 +1,5 @@
"use client";
import Link from "next/link";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
@@ -14,7 +12,7 @@ import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { cn } from "@/lib/utils";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import type { AssessmentStatus } from "@/lib/insights/status-shared";
@@ -83,6 +81,14 @@ interface InsightStatusCardProps {
* instead of only seeding the composer. Defaults to false.
*/
coachAutoSend?: boolean;
/**
* Whether the card's text is written by a model. True for every status
* note, which only shows while the `statusText` capability is available.
* The score anatomy passes false: its assessment is composed from the
* numbers and only upgraded to model text by the server, so it shows
* whatever the AI state.
*/
aiAuthored?: boolean;
}
// ─── Main Component ───────────────────────────────────────
@@ -99,13 +105,15 @@ export function InsightStatusCard({
coachQuestion,
coachScope,
coachAutoSend,
aiAuthored = true,
}: InsightStatusCardProps) {
const { t } = useTranslations();
const flags = useFeatureFlags();
// v1.4.31 — operator can hide every per-metric status card
// app-wide. The delta number stays; the LLM narration card is
// suppressed in full so the layout collapses naturally.
if (!flags.insightStatus) return null;
const statusText = useAiCapability("statusText");
// A status note is model-written text, so the card is hidden, never an
// error or a "connect a provider" hint, while the `statusText` capability
// is unavailable for any reason. The numbers around it stay; the layout
// collapses naturally.
if (aiAuthored && !statusText.available) return null;
const displayText =
assessment?.kind === "generated" ||
@@ -213,31 +221,6 @@ export function InsightStatusCard({
);
}
const isNoProvider =
assessment?.kind === "no-provider" ||
(!assessment && !hasProvider && !displayText);
if (isNoProvider) {
return (
<Card className="gap-2 py-3 opacity-80 md:py-4">
<CardHeader>
<TileHeader icon={nodeIcon(icon)} title={title} />
</CardHeader>
<CardContent className="flex flex-col gap-1.5">
<p className="text-muted-foreground text-sm">
{t("insights.noProviderConfigured")}
</p>
<Link
href="/settings/ai"
data-slot="insight-status-no-provider-cta"
className="text-primary text-sm font-medium underline-offset-4 hover:underline"
>
{t("insights.noProviderAction")}
</Link>
</CardContent>
</Card>
);
}
if (!displayText) {
return (
<Card className="gap-2 py-3 md:py-4">
@@ -5,7 +5,8 @@ import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useSurfaceVisible } from "@/hooks/use-surface-visible";
import { useWorkouts } from "@/hooks/use-workouts";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
import {
@@ -52,26 +53,18 @@ interface ComprehensivePayload {
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const { isAuthenticated, user } = useAuth();
// v1.4.33 F18 — gate the advisor POST on the operator's assistant
// feature flag. Pre-fix, every /insights mount fired POST
// /api/insights/generate even when the operator had disabled the
// briefing surface or the user had no AI provider configured. The
// server returned 422 in that case and the regenerate button on the
// tab strip rendered a non-functional spinner. Reading the flag
// matrix off `/api/feature-flags` keeps the hot path one fetch
// (shared `["feature-flags"]` cache, 60s staleTime) and skips the
// advisor request entirely when the operator has the briefing gate
// off. The fail-open default in `useFeatureFlags` means a network
// hiccup still renders the advisor — the gate only takes effect when
// the operator has explicitly turned the surface off.
const flags = useFeatureFlags();
// The briefing read and the regenerate button follow the `briefing`
// capability on `/api/auth/me`: the server resolves the operator's switch,
// the person's AI analysis opt-out, provider presence and consent into one
// answer, and the strip offers regenerate only while that answer is yes.
// Unavailable while `/me` loads, so nothing fires before the answer lands.
const briefing = useAiCapability("briefing");
// v1.16.4 — `mounted` keeps the hydration render in lockstep with the
// SSR HTML: a late-hydrating boundary can see resolved query state on
// its first render (auth + flags settled), which used to flip the
// regenerate affordance on at hydration time and trip React #418.
const mounted = useMounted();
const advisorEnabled =
mounted && isAuthenticated && flags.enabled && flags.briefing;
const advisorEnabled = mounted && isAuthenticated && briefing.available;
const advisor = useInsightsAdvisorQuery(advisorEnabled);
@@ -104,7 +97,10 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
// least one canonical row in the list-endpoint response. The
// single-row probe shares its cache slot with the list page +
// dashboard tile so navigation between surfaces is a free cache hit.
const workoutsProbe = useWorkouts({ limit: 1 });
// Only while the Workouts module shows: a switched-off module is never
// probed (its pill is hidden by the surface map regardless).
const workoutsVisible = useSurfaceVisible("insights-page:workouts");
const workoutsProbe = useWorkouts({ limit: 1, enabled: workoutsVisible });
// v1.29 — nutrients gate. `nutrients` is opt-in (default off), unlike
// the always-on metric clusters, so the pill's floor is the module
@@ -82,7 +82,8 @@ export interface InsightsTabStripProps {
* v1.15.18 — the outcome of the last settled regenerate. The falling-edge
* toast fires "refreshed" ONLY when this is `"fresh"`; a slow generation the
* client gave up on (`"timeout"`) shows a "still working" hint instead of a
* misleading success, and a missing provider (`"no-provider"`) stays silent
* misleading success, and a missing provider (`"no-provider"`) or a refused
* briefing (`"unavailable"`) stays silent
* (the surface already shows the connect-AI empty state). v1.15.20 —
* `"rate-limited"` (quota exhausted) and `"empty"` (transient 503) show
* their own honest hints instead of the success toast. Absent (legacy
@@ -94,6 +95,7 @@ export interface InsightsTabStripProps {
| "rate-limited"
| "timeout"
| "no-provider"
| "unavailable"
| "settle-failed"
| null;
/**
@@ -649,6 +651,7 @@ export function regenerateToastKind(
case "settle-failed":
return { kind: "error", messageKey: "insights.regenerateSettleFailed" };
case "no-provider":
case "unavailable":
return null;
default:
return { kind: "success", messageKey: "insights.regenerateSuccess" };
+5 -5
View File
@@ -9,8 +9,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
@@ -78,8 +77,9 @@ export function LayoutCoachFab() {
const pathname = usePathname();
const router = useRouter();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
// One answer for every Coach entry point: the `coach` capability on
// `/api/auth/me` (operator switch, Hide Coach, provider, consent).
const coach = useAiCapability("coach");
// Local "seen on this device" stamp — the nudge timestamp the user
// last dismissed by opening the chat. Lazy initialiser per the
@@ -93,7 +93,7 @@ export function LayoutCoachFab() {
}
});
const coachAvailable = !!launch && flags.coach && !disableCoach;
const coachAvailable = !!launch && coach.available;
const onCoachPage = pathname?.startsWith("/coach") ?? false;
const queryClient = useQueryClient();
+7 -12
View File
@@ -3,8 +3,7 @@
import dynamic from "next/dynamic";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { useAiCapability } from "@/hooks/use-ai-capability";
/**
* v1.4.31 — defer the Coach drawer subtree behind `next/dynamic` so
@@ -36,17 +35,13 @@ const CoachDrawer = dynamic(
*/
export function LayoutCoachMount() {
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
const coach = useAiCapability("coach");
if (!launch) return null;
// v1.4.31 — operator can hide the Coach drawer app-wide; suppress
// the entire SSE mount when the flag is off.
if (!flags.coach) return null;
// v1.4.47 W3 — per-user opt-out short-circuits the drawer subtree
// the same way the operator's master flag does. Keeps the SSE
// chat reader + portal scaffolding out of the bundle for users
// who never want the Coach mounted.
if (disableCoach) return null;
// The drawer subtree (SSE chat reader, portal scaffolding) mounts only
// while the `coach` capability is available: the operator's switch, the
// person's Hide Coach, a missing provider and missing consent all keep it
// out, so nothing offers a Coach that cannot answer.
if (!coach.available) return null;
return (
<CoachDrawer
open={launch.open}
@@ -3,6 +3,7 @@
import { useQuery } from "@tanstack/react-query";
import { TrendingUp } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
@@ -43,16 +44,15 @@ export function MoodWhatStandsOut({
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.insightsCorrelations(),
queryFn: async () => {
// 403 = operator disabled the surface; any rejection (ApiError)
// degrades to the card's isError → render-nothing path.
// Correlations are statistics, never gated on AI; a rejection is a
// real failure and renders the error row with a retry.
return apiGet<CorrelationDiscoveryResponse>("/api/insights/correlations");
},
enabled: isAuthenticated,
staleTime: 60_000,
// The surface is optional — don't retry a deliberate 403 into noise.
retry: false,
});
@@ -79,13 +79,11 @@ export function MoodWhatStandsOut({
/>
) : null}
{isError ? (
<p
data-slot="mood-discovery-error"
role="alert"
className="text-destructive text-sm"
>
{t("insights.pattern.loadError")}
</p>
<QueryErrorRow
slot="mood-discovery-error"
message={t("insights.pattern.loadError")}
onRetry={() => void refetch()}
/>
) : null}
{!isLoading && !isError && data && !hasDiscovered ? (
<p
@@ -0,0 +1,87 @@
"use client";
import { Component, type ReactNode } from "react";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations } from "@/lib/i18n/context";
/**
* One Insights overview section, fenced.
*
* The overview is a stack of independent sections, each with its own read.
* A section that fails to load already shows its own error row; this fence
* covers the other failure, a section that throws while rendering (a payload
* shape it did not expect, a chunk that no longer exists after a deploy).
* Without it the throw reaches the route boundary and replaces the whole
* page, so one broken card takes the hero, the scores and every other
* section down with it. Inside the fence the failed section becomes one row
* with a retry, and the rest of the page stays.
*
* Retry clears the fence and renders the section again; a section whose
* failure was transient comes back, one whose failure is permanent shows the
* row again.
*/
interface FenceProps {
sectionId: string;
message: string;
retryLabel: string;
children: ReactNode;
}
interface FenceState {
failed: boolean;
}
class SectionFence extends Component<FenceProps, FenceState> {
state: FenceState = { failed: false };
static getDerivedStateFromError(): FenceState {
return { failed: true };
}
componentDidCatch(error: Error): void {
// Same hand-off the chart boundary uses: the section degrades quietly,
// the failure still reaches the client error tracker.
if (typeof window !== "undefined") {
const g = window as typeof window & {
__healthlog_onError?: (err: Error) => void;
};
g.__healthlog_onError?.(error);
}
}
render(): ReactNode {
if (!this.state.failed) return this.props.children;
return (
<section
data-slot="insights-overview-section-error"
data-section={this.props.sectionId}
>
<QueryErrorRow
message={this.props.message}
retryLabel={this.props.retryLabel}
onRetry={() => this.setState({ failed: false })}
/>
</section>
);
}
}
export function OverviewSectionBoundary({
sectionId,
children,
}: {
sectionId: string;
children: ReactNode;
}) {
const { t } = useTranslations();
return (
<SectionFence
sectionId={sectionId}
message={t("insights.sectionLoadError")}
retryLabel={t("common.retry")}
>
{children}
</SectionFence>
);
}
+24 -1
View File
@@ -11,6 +11,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/ui/section-heading";
import { InsightSectionCard } from "./insight-section-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
/**
* v1.10.0 — device-flagged event awareness surface (categorical events,
@@ -89,7 +90,7 @@ export function RhythmEventsCard({
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isLoading } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.insightsRhythmEvents(),
queryFn: async () => {
try {
@@ -103,6 +104,28 @@ export function RhythmEventsCard({
enabled: enabled && isAuthenticated,
});
// A failed read is said, never swallowed: this card carries health alerts,
// and silently rendering nothing on an error would hide them.
if (isError && !data) {
return (
<section
data-slot="rhythm-events-section"
aria-label={t("insights.rhythmEvents.sectionTitle")}
className={cn("space-y-3", className)}
>
<SectionHeading
icon={Activity}
title={t("insights.rhythmEvents.sectionTitle")}
/>
<QueryErrorRow
slot="rhythm-events-error"
message={t("insights.rhythmEvents.loadError")}
onRetry={() => void refetch()}
/>
</section>
);
}
// Data-availability gate — never paint an empty / alarming card.
if (isLoading || !data || !data.hasEvents) return null;
+25 -25
View File
@@ -16,6 +16,7 @@ import type {
} from "@/lib/ai/schema";
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useAiCapability } from "@/hooks/use-ai-capability";
import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker";
/**
@@ -245,9 +246,21 @@ export function nextAdvisorPollInterval(
* regenerate quota is exhausted, which deserves a "try again later"
* hint rather than the success toast the old lump produced. `empty` now
* means only the transient 503 surface (provider chain unavailable).
*
* `unavailable` (403) is the server refusing the briefing capability: the
* operator's switch, the person's opt-out, or a consent the chain needs. The
* web only asks while `/api/auth/me` says the capability is available, so this
* is the backstop for the moment between a switch flipping and `/me`
* refreshing. It settles like the other expected refusals instead of
* throwing, so the briefing card never turns into an error.
*/
export type AdvisorFetchOutcome =
"fresh" | "empty" | "rate-limited" | "timeout" | "no-provider";
| "fresh"
| "empty"
| "rate-limited"
| "timeout"
| "no-provider"
| "unavailable";
interface AdvisorFetchResult {
payload: InsightAdvisorPayload | null;
@@ -316,6 +329,9 @@ export async function fetchAdvisor(
if (res.status === 503) {
return { payload: null, outcome: "empty" };
}
if (res.status === 403) {
return { payload: null, outcome: "unavailable" };
}
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
@@ -384,24 +400,6 @@ export interface UseInsightsAdvisorResult {
* assessment is still being prepared; NO toast fires until this falls.
*/
regenerateSettling: boolean;
/**
* v1.15.20 — the outcome of the last settled READ. Lets surfaces
* distinguish "no briefing yet, a generate could help" (`empty` /
* `timeout`) from "no provider configured, generating is futile"
* (`no-provider`) and render a connect-AI hint instead of a dead
* regenerate CTA.
*/
readOutcome: AdvisorFetchOutcome | null;
/**
* v1.18.9 (#4) — false when the GET reported no usable AI provider. The
* read path still serves the last cached briefing (no provider is
* needed to read the cache), so this is the ONLY honest signal that a
* shown-but-stale briefing can never refresh. Surfaces pair it with the
* relative-age line to add a discreet connect-provider hint. Defaults
* true (no hint) when the field is absent — a pre-field cached payload
* or an unsettled query.
*/
hasProvider: boolean;
/**
* v1.25 — true when the GET reported the last generation attempt failed.
* Pairs a shown-but-held briefing with a discreet "couldn't refresh" hint,
@@ -427,10 +425,17 @@ export interface UseInsightsAdvisorResult {
/**
* Read-only consumer for the advisor payload. Use this on surfaces that
* just want to render the cached insight (e.g. dashboard preview).
*
* The read only fires while the `briefing` capability on `/api/auth/me` is
* available, whatever the caller passes: every consumer (the overview, the
* tab strip) gets the same gate, and none can re-enable the read behind the
* other's back. While `/me` loads the capability reads unavailable, so the
* first paint never fires a request only to have it refused.
*/
export function useInsightsAdvisorQuery(
enabled: boolean,
): UseInsightsAdvisorResult {
const briefing = useAiCapability("briefing");
const queryClient = useQueryClient();
// Refs #786 — settling state for a timed-out force regenerate (see the
// contract comment above `AdvisorSettleState`). `settleOutcome` carries the
@@ -445,7 +450,7 @@ export function useInsightsAdvisorQuery(
// outcome) so the read path can surface WHY a payload is missing
// (`no-provider` → connect-AI hint instead of a dead regenerate CTA).
queryFn: () => fetchAdvisor(),
enabled,
enabled: enabled && briefing.available,
// 24h cache window matches the server-side `insightsCachedAt` TTL.
staleTime: 60 * 60 * 1000,
retry: false,
@@ -577,11 +582,6 @@ export function useInsightsAdvisorQuery(
// outcome ("timeout" while settling is an interim state, not a result).
regenerateOutcome: settleOutcome ?? mutation.data?.outcome ?? null,
regenerateSettling: settling !== null,
readOutcome: query.data?.outcome ?? null,
// Absent field (pre-v1.18.9 cached payload, or query still settling) →
// assume a provider is present so a transient unknown never flashes a
// false "no provider" hint.
hasProvider: query.data?.payload?.hasProvider ?? true,
// Absent (pre-field cached payload or unsettled query) → not failed, so a
// transient unknown never flashes a false "couldn't refresh" hint.
generationFailed: query.data?.payload?.generationFailed ?? false,
@@ -60,9 +60,10 @@ export interface WorkoutDetailDayContextProps {
* undated and named for what their targets actually deliver — trends and
* history, not "that day".
*
* Module gating: the intraday tile mounts only when `insights` is on and
* the sleep row only when `sleep` is on, so a switched-off module never
* paints as a failed request. Mood has no module gate, so the section
* Module gating: the sleep row mounts only when `sleep` is on, so a
* switched-off module never paints as a failed request. The intraday pulse
* tile is heart-rate data and follows no module (the "AI analysis" switch
* only governs model-written text). Mood has no module gate, so the section
* always carries at least one part and the heading is never an orphan.
*/
export function WorkoutDetailDayContext({
@@ -71,7 +72,6 @@ export function WorkoutDetailDayContext({
const { t } = useTranslations();
const fmt = useFormatters();
const { user, isAuthenticated } = useAuth();
const insightsEnabled = useModuleEnabled("insights");
const sleepEnabled = useModuleEnabled("sleep");
const sleep = useSleepNight(workout.dayKey, isAuthenticated && sleepEnabled);
@@ -96,12 +96,10 @@ export function WorkoutDetailDayContext({
}
/>
{insightsEnabled ? (
<IntradayPulseChart
userTimezone={user?.timezone}
initialDateKey={workout.dayKey}
/>
) : null}
<IntradayPulseChart
userTimezone={user?.timezone}
initialDateKey={workout.dayKey}
/>
<Card className="gap-2 py-3 md:py-4" data-slot="workout-detail-day-card">
<CardContent>
@@ -17,6 +17,15 @@ import type {
* the strip, the chart placeholder, and the assessment card all paint.
*/
// AI surfaces paint only while their capability is available; these tests
// cover the painted surface, so every capability reads available here.
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: () => new Promise(() => {}),
apiDelete: vi.fn(),
@@ -31,10 +40,6 @@ vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: { id: "u1" } }),
}));
vi.mock("@/hooks/use-feature-flags", () => ({
useFeatureFlags: () => ({ insightStatus: true }),
}));
import { LabBiomarkerDetail } from "../lab-biomarker-detail";
const BIOMARKER_ID = "bm-1";
@@ -10,7 +10,8 @@ describe("shouldProbeOcrCapability", () => {
labsEnabled: true,
mounted: true,
ownRecord: true,
};
labsOcr: { available: true, reason: null, onDeviceAllowed: true },
} as const;
expect(shouldProbeOcrCapability(ownerControl)).toBe(true);
expect(
@@ -26,4 +27,36 @@ describe("shouldProbeOcrCapability", () => {
false,
);
});
it("follows the labsOcr capability: probes when available or when only consent is missing", () => {
const base = {
isAuthenticated: true,
isLoading: false,
labsEnabled: true,
mounted: true,
ownRecord: true,
};
const state = (reason: string | null) =>
({
available: reason === null,
reason,
onDeviceAllowed: false,
}) as Parameters<typeof shouldProbeOcrCapability>[0]["labsOcr"];
// Consent is asked for inside the scan dialog, so the scan stays offered.
expect(
shouldProbeOcrCapability({ ...base, labsOcr: state("consent_required") }),
).toBe(true);
for (const reason of [
"operator_disabled",
"no_provider",
"module_disabled",
"not_permitted_for_record",
"check_failed",
]) {
expect(
shouldProbeOcrCapability({ ...base, labsOcr: state(reason) }),
).toBe(false);
}
});
});
@@ -5,7 +5,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { handleFilePickerChange, OcrReviewDialog } from "../ocr-review-dialog";
import { ApiError } from "@/lib/api/api-fetch";
import {
extractErrorMessage,
handleFilePickerChange,
OcrReviewDialog,
} from "../ocr-review-dialog";
const hookState = vi.hoisted(() => ({ extractPending: false }));
@@ -44,13 +50,25 @@ vi.mock("../use-ocr-extract", () => ({
}),
}));
vi.mock("@/hooks/use-grant-document-reading-consent", () => ({
useGrantDocumentReadingConsent: () => ({
isPending: false,
isError: false,
mutate: vi.fn(),
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
function render(
locale: "en" | "de" = "en",
options: { mode?: "vision" | "text"; pdfSupported?: boolean } = {},
options: {
mode?: "vision" | "text";
pdfSupported?: boolean;
consentRequired?: boolean;
} = {},
): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
@@ -59,6 +77,7 @@ function render(
onOpenChange={() => {}}
mode={options.mode ?? "vision"}
pdfSupported={options.pdfSupported ?? true}
consentRequired={options.consentRequired}
onCommitted={() => {}}
/>
</I18nProvider>,
@@ -138,3 +157,47 @@ describe("<OcrReviewDialog> file picker", () => {
expect(input.value).toBe("");
});
});
describe("<OcrReviewDialog> document-reading consent", () => {
it("asks for the reading consent in place of the picker while it is missing", () => {
const html = render("en", { consentRequired: true });
expect(html).toContain('data-slot="document-reading-consent"');
expect(html).not.toMatch(/<input[^>]*type="file"/);
});
it("shows the picker once consent is in place", () => {
const html = render("en", { consentRequired: false });
expect(html).not.toContain('data-slot="document-reading-consent"');
expect(html).toMatch(/<input[^>]*type="file"/);
});
});
describe("extractErrorMessage", () => {
const t = (key: string) => key;
const refusal = (errorCode: string, status = 403) =>
new ApiError("refused", status, { errorCode });
it("reads the refusal by its code, not by the status", () => {
expect(extractErrorMessage(refusal("consent.ai.required"), t)).toBe(
"labs.ocr.consentRequired",
);
expect(
extractErrorMessage(refusal("assistant.disabled.documentAi"), t),
).toBe("labs.ocr.aiUnavailable");
expect(extractErrorMessage(refusal("ai.record.notPermitted"), t)).toBe(
"labs.ocr.aiUnavailable",
);
expect(extractErrorMessage(refusal("module.disabled"), t)).toBe(
"labs.ocr.aiUnavailable",
);
expect(extractErrorMessage(refusal("ai.provider.none", 422), t)).toBe(
"labs.ocr.providerUnsupported",
);
});
it("never calls an unrelated 403 a missing consent", () => {
expect(extractErrorMessage(new ApiError("forbidden", 403), t)).toBe(
"labs.ocr.extractFailed",
);
});
});
+34 -4
View File
@@ -9,6 +9,7 @@
* confirms. Reuses the labs design language via `ResponsiveSheet` + the labs
* row editor.
*/
import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-consent-prompt";
import { useId, useMemo, useState } from "react";
import { Loader2, ScanLine, Upload } from "lucide-react";
@@ -81,8 +82,15 @@ function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
};
}
/** Translate an extract failure into a friendly, error-code-aware message. */
function extractErrorMessage(err: unknown, t: (key: string) => string): string {
/**
* Translate an extract failure into a friendly, error-code-aware message.
* Refusals are read by `meta.errorCode`: a 403 is a missing consent only when
* the code says so, never by its status alone.
*/
export function extractErrorMessage(
err: unknown,
t: (key: string) => string,
): string {
if (err instanceof ApiError) {
const code =
typeof err.meta?.errorCode === "string" ? err.meta.errorCode : null;
@@ -99,10 +107,23 @@ function extractErrorMessage(err: unknown, t: (key: string) => string): string {
return t("labs.ocr.fileType");
case "labs.ocr.pdfNeedsAnthropic":
return t("labs.ocr.pdfNeedsAnthropic");
case "consent.ai.required":
return t("labs.ocr.consentRequired");
case "ai.provider.none":
return t("labs.ocr.providerUnsupported");
default:
break;
}
if (err.status === 403) return t("labs.ocr.consentRequired");
// The operator's switch, the module, somebody else's record: scanning is
// not available here right now, whatever the exact layer.
if (
code !== null &&
(code.startsWith("assistant.disabled.") ||
code === "ai.record.notPermitted" ||
code === "module.disabled")
) {
return t("labs.ocr.aiUnavailable");
}
}
return t("labs.ocr.extractFailed");
}
@@ -112,6 +133,7 @@ export function OcrReviewDialog({
onOpenChange,
mode,
pdfSupported,
consentRequired = false,
onCommitted,
}: {
open: boolean;
@@ -119,6 +141,12 @@ export function OcrReviewDialog({
/** Native vision vs in-browser local OCR. Text mode is image-only. */
mode: OcrMode;
pdfSupported: boolean;
/**
* The `labsOcr` capability is missing only the document-reading consent.
* The pick step asks for it instead of offering the file picker; once it is
* granted `/api/auth/me` refreshes and the picker appears.
*/
consentRequired?: boolean;
onCommitted: () => void;
}) {
const { t } = useTranslations();
@@ -270,7 +298,9 @@ export function OcrReviewDialog({
) : undefined
}
>
{stage === "pick" ? (
{stage === "pick" && consentRequired ? (
<DocumentReadingConsentPrompt />
) : stage === "pick" ? (
<div className="relative py-2">
<input
id={pickerId}
+21 -2
View File
@@ -11,6 +11,7 @@
* - `useOcrCommit()` — writes the user-confirmed rows and invalidates the
* labs + biomarker query keys.
*/
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
@@ -41,13 +42,22 @@ export interface OcrCommitResult extends OcrCommitResponseDto {
inserted: LabResultDto[];
}
/** The capability route resolves the caller's own provider configuration. */
/**
* The capability route resolves the caller's own provider configuration.
*
* It only runs while the `labsOcr` capability on `/api/auth/me` could let a
* scan happen: available, or missing nothing but the document-reading
* consent, which the scan dialog asks for in place. Any other reason (the
* operator's switch, no provider, the module, somebody else's record) means
* no scan is offered, so there is nothing to probe.
*/
export function shouldProbeOcrCapability({
isAuthenticated,
isLoading,
labsEnabled,
mounted,
ownRecord,
labsOcr,
}: {
isAuthenticated: boolean;
isLoading: boolean;
@@ -55,8 +65,17 @@ export function shouldProbeOcrCapability({
mounted: boolean;
/** In the caller's own record; the probe and the commit resolve the caller, never a grant. */
ownRecord: boolean;
labsOcr: AiCapabilityState;
}): boolean {
return isAuthenticated && !isLoading && labsEnabled && mounted && ownRecord;
const aiOffered = labsOcr.available || labsOcr.reason === "consent_required";
return (
isAuthenticated &&
!isLoading &&
labsEnabled &&
mounted &&
ownRecord &&
aiOffered
);
}
/** Capability probe — refetched when the scan dialog opens. */
+8 -4
View File
@@ -20,6 +20,7 @@ import {
import { isSurfaceVisible } from "@/lib/modules/surface";
import { useCallback, useMemo, useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useNavModules } from "@/hooks/use-nav-modules";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
@@ -107,15 +108,18 @@ export function BottomNav() {
// feature destination that isn't a primary slot, plus the shared utility
// tail. Cycle is gated by the same flag the sidebar uses, so the two
// surfaces gate identically and cannot drift.
// The Coach entry follows the `coach` AI capability (a Coach that cannot
// answer is not offered); the sidebar reads the same map.
const navModules = useNavModules();
const moreHub = useMemo<ReadonlyArray<NavLink>>(
() =>
mobileMoreHubDestinations({
modules: user?.modules,
modules: navModules,
mounted,
sharedRecord,
sections,
}),
[user?.modules, mounted, sharedRecord, sections],
[navModules, mounted, sharedRecord, sections],
);
// Every slot but Home asks two things: does sharing cover it, and does its
@@ -133,10 +137,10 @@ export function BottomNav() {
// Insights drops out under a switch.
(!sharedRecord || isDestinationInSharedRecord(item.href, sections)) &&
mounted &&
isSurfaceVisible(`nav:${item.href}`, user?.modules)
isSurfaceVisible(`nav:${item.href}`, navModules)
);
},
[user?.modules, mounted, sharedRecord, sections],
[navModules, mounted, sharedRecord, sections],
);
// Both fixed flanks run through the ONE slot filter, so a module-gated
+6 -3
View File
@@ -29,6 +29,7 @@ import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useNavModules } from "@/hooks/use-nav-modules";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTheme } from "@/components/providers";
@@ -320,10 +321,12 @@ export function SidebarNav() {
// The Settings entry needs them: a record-content page is offered only when
// the grant manages the section its forms write.
const manageableDomains = user?.accountAccess?.active?.manageableDomains;
// The Coach entry follows the `coach` AI capability, like every other
// Coach entry point; the rest of the map is `modules` unchanged.
const navModules = useNavModules();
const visibleNavItems = useMemo(
() =>
visibleNavDestinations(user?.modules, mounted, sharedRecord, sections),
[user?.modules, mounted, sharedRecord, sections],
() => visibleNavDestinations(navModules, mounted, sharedRecord, sections),
[navModules, mounted, sharedRecord, sections],
);
// Collapsed = the user's stored choice, or — with no stored choice — a
// viewport default: tablet widths (md–lg, e.g. an iPad held upright)
@@ -0,0 +1,45 @@
/**
* The medication text reader says why it could not read, from the refusal's
* code. Before, a spent daily budget (429 `coach.budget.exceeded`) showed the
* rate-limit sentence, and a switched-off reader or a missing consent showed
* "check your connection".
*/
import { describe, expect, it } from "vitest";
import { classifyExtractFailure } from "../natural-language-extractor";
const envelope = (errorCode?: string, error = "refused") => ({
data: null,
error,
...(errorCode ? { meta: { errorCode } } : {}),
});
describe("classifyExtractFailure", () => {
it("reads the code before the status", () => {
expect(
classifyExtractFailure(429, envelope("coach.budget.exceeded")).kind,
).toBe("budget");
expect(classifyExtractFailure(429, envelope()).kind).toBe("rateLimit");
});
it("names a missing provider, a missing consent and a switched-off reader", () => {
expect(classifyExtractFailure(503, envelope("ai.provider.none")).kind).toBe(
"noProvider",
);
expect(
classifyExtractFailure(403, envelope("consent.ai.required")).kind,
).toBe("consent");
expect(
classifyExtractFailure(403, envelope("assistant.disabled.documentAi"))
.kind,
).toBe("unavailable");
expect(
classifyExtractFailure(403, envelope("ai.record.notPermitted")).kind,
).toBe("unavailable");
});
it("keeps a bare 503 as no provider and anything else as a network failure", () => {
expect(classifyExtractFailure(503, null).kind).toBe("noProvider");
expect(classifyExtractFailure(500, envelope()).kind).toBe("network");
});
});
@@ -46,6 +46,7 @@ import { Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-consent-prompt";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
@@ -77,6 +78,12 @@ export interface NaturalLanguageExtractorProps {
* point at a mock server. Defaults to the production route.
*/
endpoint?: string;
/**
* The `medicationExtract` capability is missing only the document-reading
* consent. The sheet asks for it above the text field and holds the submit
* until `/api/auth/me` reports it granted.
*/
consentRequired?: boolean;
}
type DialogError =
@@ -85,8 +92,43 @@ type DialogError =
| { kind: "rateLimit" }
| { kind: "noProvider" }
| { kind: "budget" }
| { kind: "consent" }
| { kind: "unavailable" }
| { kind: "network"; message?: string };
interface ExtractFailureBody {
error?: string | null;
meta?: { errorCode?: unknown } | null;
}
/**
* Classify a failed extract response. The refusal's `meta.errorCode` decides
* first, because a status is shared by different reasons: a 429 is either
* the per-minute limit or the spent daily budget, and a 403 is a missing
* consent, the operator's switch or somebody else's record.
*/
export function classifyExtractFailure(
status: number,
body: ExtractFailureBody | null,
): DialogError {
const code =
typeof body?.meta?.errorCode === "string" ? body.meta.errorCode : null;
if (code === "coach.budget.exceeded") return { kind: "budget" };
if (code === "ai.provider.none") return { kind: "noProvider" };
if (code === "consent.ai.required") return { kind: "consent" };
if (
code !== null &&
(code.startsWith("assistant.disabled.") ||
code === "ai.record.notPermitted" ||
code === "module.disabled")
) {
return { kind: "unavailable" };
}
if (status === 429) return { kind: "rateLimit" };
if (status === 503) return { kind: "noProvider" };
return { kind: "network", message: body?.error ?? undefined };
}
const MAX_TEXT_LENGTH = 2000;
export function NaturalLanguageExtractor({
@@ -95,6 +137,7 @@ export function NaturalLanguageExtractor({
onPrefill,
locale,
endpoint = "/api/medications/extract",
consentRequired = false,
}: NaturalLanguageExtractorProps) {
const { t } = useTranslations();
const textareaId = useId();
@@ -143,23 +186,11 @@ export function NaturalLanguageExtractor({
signal: controller.signal,
});
if (res.status === 429) {
setError({ kind: "rateLimit" });
return;
}
if (res.status === 503) {
setError({ kind: "noProvider" });
return;
}
if (!res.ok) {
const body = (await res.json().catch(() => null)) as {
error?: string;
} | null;
if (body?.error && /budget/i.test(body.error)) {
setError({ kind: "budget" });
return;
}
setError({ kind: "network", message: body?.error });
const body = (await res
.json()
.catch(() => null)) as ExtractFailureBody | null;
setError(classifyExtractFailure(res.status, body));
return;
}
@@ -228,7 +259,7 @@ export function NaturalLanguageExtractor({
<Button
type="button"
onClick={handleSubmit}
disabled={busy || overLimit}
disabled={busy || overLimit || consentRequired}
aria-busy={busy || undefined}
className="min-h-11 sm:min-h-9"
>
@@ -248,6 +279,7 @@ export function NaturalLanguageExtractor({
}
>
<div className="flex flex-col gap-3" aria-busy={busy || undefined}>
{consentRequired ? <DocumentReadingConsentPrompt /> : null}
<div>
<Label htmlFor={textareaId} className="sr-only">
{t("medications.scheduling.naturalLanguage.title")}
@@ -348,6 +380,10 @@ function resolveErrorMessage(
return t("medications.scheduling.naturalLanguage.error.noProvider");
case "budget":
return t("medications.scheduling.naturalLanguage.error.budget");
case "consent":
return t("medications.scheduling.naturalLanguage.error.consent");
case "unavailable":
return t("medications.scheduling.naturalLanguage.error.unavailable");
case "network":
default:
return t("medications.scheduling.naturalLanguage.error.network");
@@ -30,6 +30,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { toast } from "sonner";
import {
Bell,
@@ -127,6 +128,14 @@ function WizardDialogShell({
navigateOnCreate = true,
}: MedicationWizardDialogProps) {
const { t, locale } = useTranslations();
// The "describe it in words" reader follows the `medicationExtract`
// capability: offered while available, and while only the document-reading
// consent is missing (the sheet asks for it). Otherwise the button is not
// there at all, and the manual steps are the whole wizard.
const medicationExtract = useAiCapability("medicationExtract");
const extractOffered =
medicationExtract.available ||
medicationExtract.reason === "consent_required";
const router = useRouter();
const queryClient = useQueryClient();
@@ -489,7 +498,7 @@ function WizardDialogShell({
lastLabel={t("medications.wizard.nav.jumpLast")}
srLabel={stepOf}
/>
{step === 1 && mode === "create" && (
{step === 1 && mode === "create" && extractOffered && (
<div className="flex justify-end">
<Button
type="button"
@@ -612,7 +621,7 @@ function WizardDialogShell({
{/* Natural-language extractor — same dialog the v1.5.3 page mounted.
Only available on the create path; edit flows already know
what the medication looks like. */}
{mode === "create" && (
{mode === "create" && extractOffered && (
<NaturalLanguageExtractor
open={nlOpen}
onClose={() => setNlOpen(false)}
@@ -620,6 +629,7 @@ function WizardDialogShell({
applyPartial(extractorToWizardPartial(partial));
}}
locale={locale}
consentRequired={medicationExtract.reason === "consent_required"}
/>
)}
</>
@@ -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", () => {
+148 -138
View File
@@ -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
+24 -2
View File
@@ -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>
);
}
@@ -75,6 +75,35 @@ describe("AiConsentCard", () => {
expect(html).toContain('data-slot="ai-consent-grant"');
});
it("never reports a failed read as a withdrawal", () => {
// A refused or failed read (the consent bucket's rate limit, a network
// drop) says nothing about the decision. Reporting it as "Withdrawn" and
// offering a grant would misstate the person's own choice.
const client = new QueryClient({
defaultOptions: { queries: { retry: false, retryOnMount: false } },
});
client
.getQueryCache()
.build(client, { queryKey: receiptKey })
.setState({
status: "error",
error: new Error("read failed"),
data: undefined,
fetchStatus: "idle",
});
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AiConsentCard isAuthenticated />
</I18nProvider>
</QueryClientProvider>,
);
expect(html).not.toContain("Withdrawn.");
expect(html).not.toContain('data-slot="ai-consent-grant"');
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
expect(html).toContain('data-slot="ai-consent-load-error"');
});
it("never shows the grant control while consent stands", () => {
const html = renderWith({
id: "rcpt-1",
+145 -6
View File
@@ -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";
@@ -26,6 +35,7 @@ import { Loader2, ShieldCheck, ShieldOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -47,11 +57,13 @@ export function AiConsentCard({
const queryClient = useQueryClient();
const [confirming, setConfirming] = useState(false);
const { data, isLoading } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
queryFn: async () => {
const res = await apiFetchRaw("/api/consent/ai/latest?kind=ai_full");
if (!res.ok) return null;
// A failed read is not a withdrawal: throw so the card says it could
// not load instead of reporting the person's decision wrongly.
if (!res.ok) throw new Error("consent read failed");
const json = await res.json();
return (json.data?.receipt ?? null) as ConsentReceiptWire;
},
@@ -69,10 +81,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 +110,7 @@ export function AiConsentCard({
await queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
});
await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
await queryClient.invalidateQueries({
queryKey: queryKeys.insightsProviderChain(),
});
@@ -121,6 +136,17 @@ export function AiConsentCard({
);
}
if (isError) {
return (
<section className="space-y-3" data-slot="ai-consent">
<QueryErrorRow
slot="ai-consent-load-error"
onRetry={() => void refetch()}
/>
</section>
);
}
// Narrow to the receipt itself rather than a boolean, so the branches
// below can read its fields without an assertion.
const activeReceipt = data && data.revokedAt === null ? data : null;
@@ -217,6 +243,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>
);
}
+20 -2
View File
@@ -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
/>
</>
);
}
+9 -4
View File
@@ -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
+9 -9
View File
@@ -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