diff --git a/src/app/coach/__tests__/page-interrupted-resume.test.tsx b/src/app/coach/__tests__/page-interrupted-resume.test.tsx index c01191e27..f3d22ebfc 100644 --- a/src/app/coach/__tests__/page-interrupted-resume.test.tsx +++ b/src/app/coach/__tests__/page-interrupted-resume.test.tsx @@ -37,11 +37,12 @@ vi.mock("next/navigation", () => ({ vi.mock("@/lib/insights/coach-launch-context", () => ({ useCoachLaunch: () => ({ askCoach: vi.fn() }), })); -vi.mock("@/hooks/use-feature-flags", () => ({ - useFeatureFlags: () => ({ coach: true }), -})); -vi.mock("@/hooks/use-disable-coach", () => ({ - useDisableCoach: () => false, +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiCapabilityAnswer: () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: {}, isLoading: false }), diff --git a/src/app/coach/__tests__/page-launch-params.test.tsx b/src/app/coach/__tests__/page-launch-params.test.tsx index 3f56c28c8..82b79143f 100644 --- a/src/app/coach/__tests__/page-launch-params.test.tsx +++ b/src/app/coach/__tests__/page-launch-params.test.tsx @@ -38,11 +38,12 @@ vi.mock("next/navigation", () => ({ vi.mock("@/lib/insights/coach-launch-context", () => ({ useCoachLaunch: () => ({ askCoach: vi.fn() }), })); -vi.mock("@/hooks/use-feature-flags", () => ({ - useFeatureFlags: () => ({ coach: true }), -})); -vi.mock("@/hooks/use-disable-coach", () => ({ - useDisableCoach: () => false, +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiCapabilityAnswer: () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: {}, isLoading: false }), diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 1d276bc48..1d3048e6d 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { @@ -19,6 +19,7 @@ import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread"; import { ConversationRename } from "@/components/insights/coach-panel/conversation-rename"; +import { ConversationTranscript } from "@/components/insights/coach-panel/conversation-transcript"; import { useCoachConversationHistory, useDeleteCoachConversationWithUndo, @@ -26,8 +27,7 @@ import { import type { CoachConversationDTO } from "@/lib/ai/coach/types"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; -import { useFeatureFlags } from "@/hooks/use-feature-flags"; -import { useDisableCoach } from "@/hooks/use-disable-coach"; +import { useAiCapabilityAnswer } from "@/hooks/use-ai-capability"; import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel"; import { useTranslations } from "@/lib/i18n/context"; import { formatRelativeTime } from "@/lib/i18n/relative-time"; @@ -50,10 +50,12 @@ import { cn } from "@/lib/utils"; * consumes — the two surfaces can no longer drift onto different * pagination behaviour. * - * Gating mirrors `/coach`: the operator master flag OR a per-user opt-out - * hides the Coach entirely and redirects back to `/insights` rather than - * painting a dead shell. The full-bleed sizing wrapper matches the Coach - * page so the two surfaces share one chrome. + * Stored conversations are the person's own records, so this page stays + * reachable while the Coach is unavailable for any reason (the operator's + * switch, Hide Coach, no provider, missing consent). It then turns read-only: + * a row opens its transcript inline instead of the Coach, and delete stays, + * so a thread can always be reviewed and erased. The full-bleed sizing + * wrapper matches the Coach page so the two surfaces share one chrome. */ type RecencyGroupId = "today" | "yesterday" | "thisWeek" | "earlier"; @@ -108,8 +110,9 @@ function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] { .filter((g) => g.conversations.length > 0); } -function CoachConversationsBody() { +function CoachConversationsBody({ readOnly }: { readOnly: boolean }) { const { t, locale } = useTranslations(); + const [openId, setOpenId] = useState(null); const router = useRouter(); const [filter, setFilter] = useState(""); // v1.30.2 (QoL H1) — search now drives the server-side title query; @@ -149,6 +152,11 @@ function CoachConversationsBody() { }); function handleSelect(id: string) { + if (readOnly) { + // No Coach to open the thread in: show its transcript right here. + setOpenId((current) => (current === id ? null : id)); + return; + } // Reuse the existing `?c=` open mechanism on the Coach page. router.push(`/coach?c=${id}`); } @@ -175,18 +183,24 @@ function CoachConversationsBody() { {t("insights.coach.historyTitle")} } + description={ + readOnly ? t("insights.coach.historyReadOnlyNote") : undefined + } actions={ // Surface the sibling Plans ledger — otherwise it is reachable - // only from the composer's `+` menu. - + // only from the composer's `+` menu. Plans need the Coach, so the + // link stays away while it is unavailable. + readOnly ? undefined : ( + + ) } />
@@ -257,7 +271,7 @@ function CoachConversationsBody() { key={c.id} data-slot="coach-conversations-item" className={cn( - "group relative flex items-center gap-1.5 rounded-xl px-3 py-2.5", + "group relative flex flex-wrap items-center gap-1.5 rounded-xl px-3 py-2.5", "text-sm transition-colors", "text-muted-foreground hover:bg-muted/40 hover:text-foreground", )} @@ -267,6 +281,7 @@ function CoachConversationsBody() { onClick={() => handleSelect(c.id)} className="flex min-h-11 min-w-0 flex-1 flex-col justify-center text-left" data-slot="coach-conversations-select" + aria-expanded={readOnly ? openId === c.id : undefined} > {/* v1.29.x (S7) — badge fenced threads (they run on @@ -294,7 +309,9 @@ function CoachConversationsBody() { {formatRelativeTime(c.updatedAt, t, locale)} - + {readOnly ? null : ( + + )} + {readOnly && openId === c.id ? ( +
+ +
+ ) : null} ); })} @@ -339,27 +361,16 @@ function CoachConversationsBody() { } export default function CoachConversationsPage() { - const router = useRouter(); const launch = useCoachLaunch(); - const flags = useFeatureFlags(); - const disableCoach = useDisableCoach(); - - const coachUnavailable = !flags.coach || disableCoach; - - // Same gating as `/coach`: operator master flag OR per-user opt-out - // redirects back to the Insights mother page so the route is never a - // dead-end. - useEffect(() => { - if (coachUnavailable) { - router.replace("/insights"); - } - }, [coachUnavailable, router]); + // `null` until `/me` answers; then the page renders either way, read-only + // while the `coach` capability is unavailable. + const coach = useAiCapabilityAnswer("coach"); // Keep the launch context referenced so the shared FAB drawer the page // hands back to stays mounted (mirrors `/coach`). void launch; - if (coachUnavailable) return null; + if (coach === null) return null; return (
- +
); } diff --git a/src/app/coach/page-client.tsx b/src/app/coach/page-client.tsx index cef46e764..6ac9fbfc9 100644 --- a/src/app/coach/page-client.tsx +++ b/src/app/coach/page-client.tsx @@ -9,8 +9,7 @@ import { COACH_INTERRUPTED_STORAGE_KEY } from "@/components/insights/coach-panel import type { CoachNudgeStatus } from "@/components/insights/layout-coach-fab"; 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 { useAiCapabilityAnswer } from "@/hooks/use-ai-capability"; import { useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { queryKeys } from "@/lib/query-keys"; @@ -208,24 +207,26 @@ function CoachPageBody() { export default function CoachPageClient() { const router = useRouter(); const launch = useCoachLaunch(); - const flags = useFeatureFlags(); - const disableCoach = useDisableCoach(); + // The `coach` capability on `/api/auth/me` is the one answer every Coach + // entry point reads. `null` until `/me` has answered, so a direct visit is + // never sent away on the loading frame. + const coach = useAiCapabilityAnswer("coach"); const { user, isLoading } = useAuth(); const { inSharedRecord } = useRecordCapabilities(); - const coachUnavailable = !flags.coach || disableCoach; + const coachUnavailable = coach === null || !coach.available; - // Coach surface gating mirrors `` / ``: - // operator master flag OR per-user opt-out hides the Coach entirely. - // Send a direct navigator back to the Insights mother page so the - // route is never a dead-end. + // With the Coach unavailable for any reason (operator switch, Hide Coach, + // no provider, missing consent) a direct navigator goes back to the + // Insights overview, so the route is never a dead end. Stored + // conversations stay reachable under /coach/conversations. const canRenderOwnCoach = user !== null && !isLoading && !inSharedRecord; useEffect(() => { - if (canRenderOwnCoach && coachUnavailable) { + if (canRenderOwnCoach && coach !== null && !coach.available) { router.replace("/insights"); } - }, [canRenderOwnCoach, coachUnavailable, router]); + }, [canRenderOwnCoach, coach, router]); // Reopening the bottom-right FAB drawer is no longer wired to a page // control, but keep the launch context referenced so the lint rule does diff --git a/src/app/coach/plans/page.tsx b/src/app/coach/plans/page.tsx index daa23c41d..04ab0dec6 100644 --- a/src/app/coach/plans/page.tsx +++ b/src/app/coach/plans/page.tsx @@ -24,8 +24,7 @@ import { useCoachPlanMutations, type CoachPlanDTO, } from "@/hooks/use-coach-plans"; -import { useFeatureFlags } from "@/hooks/use-feature-flags"; -import { useDisableCoach } from "@/hooks/use-disable-coach"; +import { useAiCapabilityAnswer } from "@/hooks/use-ai-capability"; import { useTranslations } from "@/lib/i18n/context"; import { formatDateOrRelative } from "@/lib/format"; import { cn } from "@/lib/utils"; @@ -279,21 +278,17 @@ function CoachPlansBody() { export default function CoachPlansPage() { const router = useRouter(); - const flags = useFeatureFlags(); - const disableCoach = useDisableCoach(); + // Same gate as `/coach`: the `coach` capability, acted on only once + // `/me` has answered. + const coach = useAiCapabilityAnswer("coach"); - const coachUnavailable = !flags.coach || disableCoach; - - // Same gating as `/coach`: operator master flag OR per-user opt-out - // redirects back to the Insights mother page so the route is never a - // dead-end. useEffect(() => { - if (coachUnavailable) { + if (coach !== null && !coach.available) { router.replace("/insights"); } - }, [coachUnavailable, router]); + }, [coach, router]); - if (coachUnavailable) return null; + if (coach === null || !coach.available) return null; return (
({ + 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( + + + {node} + + , + ); +} + +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: () => , + proof: 'data-slot="coach-launch-inline"', + }, + { + name: "AskCoachAction", + mount: () => , + proof: 'data-slot="ask-coach-action"', + }, + { + name: "LayoutCoachFab", + mount: () => , + 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: () => , 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"); + }); + } + } +}); diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx index 5ad010ae3..5a6b43446 100644 --- a/src/components/insights/__tests__/layout-coach-fab.test.tsx +++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx @@ -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, diff --git a/src/components/insights/ask-coach-action.tsx b/src/components/insights/ask-coach-action.tsx index c0e038126..b04783514 100644 --- a/src/components/insights/ask-coach-action.tsx +++ b/src/components/insights/ask-coach-action.tsx @@ -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 / : 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"); diff --git a/src/components/insights/coach-launch-button.tsx b/src/components/insights/coach-launch-button.tsx index 35433bf87..3b8802484 100644 --- a/src/components/insights/coach-launch-button.tsx +++ b/src/components/insights/coach-launch-button.tsx @@ -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 // `` for the matching FAB gate. - if (disableCoach) return null; + if (!coach.available) return null; const accessibleLabel = label ?? t("insights.heroActionAskCoach"); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 94498638a..114883526 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -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", diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 5651c244c..02057310a 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -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.` 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") && ( + {t("common.loading")} +

+ ); + } + if (isError || !data) { + return ( + void refetch()} + slot="coach-conversations-transcript-error" + /> + ); + } + return ( +
    + {data.messages.map((message) => ( +
  1. + + {message.role === "user" + ? t("insights.coach.transcriptYou") + : t("insights.coach.transcriptCoach")} + +

    + {message.content} +

    +
  2. + ))} +
+ ); +} diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index a08d4a325..a46aff9ef 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -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 { diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index 19bc4f3c4..df0f850de 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -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(); diff --git a/src/components/insights/layout-coach-mount.tsx b/src/components/insights/layout-coach-mount.tsx index 84d6ff8f0..a997c1b44 100644 --- a/src/components/insights/layout-coach-mount.tsx +++ b/src/components/insights/layout-coach-mount.tsx @@ -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 ( >( () => 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 diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 4d8dfc467..993dda13e 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -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) diff --git a/src/hooks/use-disable-coach.ts b/src/hooks/use-disable-coach.ts deleted file mode 100644 index fa1eaca0e..000000000 --- a/src/hooks/use-disable-coach.ts +++ /dev/null @@ -1,34 +0,0 @@ -"use client"; - -import { useAuth } from "@/hooks/use-auth"; -import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe"; - -/** - * v1.4.47 W3 — SSR-safe accessor for the per-user "Hide Coach" toggle. - * - * `useAuth()` calls `useQuery` directly and crashes when the caller - * SSR-renders without a `` (the legacy - * Coach-surface unit tests do exactly this). The cascade-test fixture - * + the dedicated disable-cascade fixture both seed a client, but the - * long-tail SSR snapshot tests (``, ``, - * `` …) render the component in isolation and don't. - * - * v1.4.48 M4 — share the ``-mount detection with - * `useFeatureFlags()` via the `_internal/use-query-client-safe` - * helper. When no client is mounted we short-circuit to the "no - * opt-out" default; the inner hook is only invoked once the provider - * is known to be in the tree, so the conditional call is safe per the - * Rules of Hooks (the branch is stable across the component's - * lifetime). - */ -export function useDisableCoach(): boolean { - const hasClient = useQueryClientMounted(); - if (!hasClient) return false; - // eslint-disable-next-line react-hooks/rules-of-hooks - return useDisableCoachInner(); -} - -function useDisableCoachInner(): boolean { - const { user } = useAuth(); - return user?.disableCoach ?? false; -} diff --git a/src/hooks/use-module-enabled.ts b/src/hooks/use-module-enabled.ts index fd4d6bf3e..5d405125a 100644 --- a/src/hooks/use-module-enabled.ts +++ b/src/hooks/use-module-enabled.ts @@ -7,7 +7,7 @@ import type { ModuleKey } from "@/lib/modules/registry"; /** * v1.18.0 R4 — SSR-safe accessor for a per-account module flag. * - * Mirrors `useDisableCoach()`: `useAuth()` calls `useQuery` directly and + * `useAuth()` calls `useQuery` directly and * crashes when a legacy presentational component (``, the * isolated score-card snapshots, …) renders without a * ``. We gate the inner hook on diff --git a/src/hooks/use-nav-modules.ts b/src/hooks/use-nav-modules.ts new file mode 100644 index 000000000..105fff65b --- /dev/null +++ b/src/hooks/use-nav-modules.ts @@ -0,0 +1,32 @@ +"use client"; + +import { useMemo } from "react"; + +import { useAiCapability } from "@/hooks/use-ai-capability"; +import { useAuth } from "@/hooks/use-auth"; +import type { SurfaceModuleMap } from "@/lib/modules/surface"; + +/** + * The module map the navigation filters by, with the Coach entry following + * the `coach` AI capability. + * + * `modules.coach` answers the operator's switch and the person's Hide Coach, + * but not a missing provider or missing consent, so on its own it would keep + * a Coach entry in the nav that opens a Coach unable to answer. The + * capability answers all of them, and every other Coach entry point (the + * floating button, the launch buttons, the Coach pages) already reads it. + * The sidebar and the bottom bar both take their map from here so the two + * cannot disagree. + */ +export function useNavModules(): SurfaceModuleMap | undefined { + const { user } = useAuth(); + const coach = useAiCapability("coach"); + const modules = user?.modules; + return useMemo( + () => + modules === undefined + ? undefined + : { ...modules, coach: modules.coach !== false && coach.available }, + [modules, coach.available], + ); +} diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx deleted file mode 100644 index 0b5adcbfb..000000000 --- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx +++ /dev/null @@ -1,404 +0,0 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join, relative, resolve } from "node:path"; - -import React from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; -import { - DEFAULT_ASSISTANT_FLAGS, - type AssistantFlagSet, -} from "@/hooks/use-feature-flags"; - -import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; -import { LayoutCoachFab } from "@/components/insights/layout-coach-fab"; -import { LayoutCoachMount } from "@/components/insights/layout-coach-mount"; - -// v1.4.38 W-C M-5 — capture the original `useFeatureFlags` and route -// the spy through it so the gate-fired assertion below can verify the -// hook ran with the operator-disabled matrix. Setting `vi.mock()` here -// (instead of `vi.spyOn`) is required because the components import the -// hook at module load; a runtime `spyOn` would never intercept the call. -const featureFlagsSpy = vi.fn<() => AssistantFlagSet>( - () => DEFAULT_ASSISTANT_FLAGS, -); -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: () => featureFlagsSpy(), - }; -}); - -// v1.16.1 — the nudge-driven `` reads the app router -// (push + pathname); SSR test renders have no router context. -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/insights", -})); - -/** - * v1.4.37 W5 — Coach disable cascade invariant. - * - * The maintainer directive: when the operator turns the global Coach flag off, - * every Coach affordance on every surface must vanish — no - * grey-out, no error, no inert button, no DOM trace. The web app - * gates each surface via `useFeatureFlags()` and a `flags.coach` - * short-circuit; the invariant below pins the contract so a future - * surface addition can't silently leak the affordance. - * - * How to extend: when a new Coach-bearing surface lands (button, - * drawer, chip strip, FAB, inline pill), add an entry to the - * `COACH_SURFACES` fixture below and bump the `expect(... .length)` - * count in the trailing sync-check. The contract per surface is - * "renders nothing measurable in the SSR output when the Coach flag - * is off"; surfaces that need data-slot probes should expose a - * stable `data-slot="coach-*"` attribute the test can grep. - * - * The hook layer is covered by `src/hooks/__tests__/ - * use-feature-flags.test.tsx`; this file is the integration-style - * contract that walks each surface and asserts the gate is present. - */ - -/** - * Build a QueryClient pre-seeded with the operator's flag matrix so - * every `useFeatureFlags()` consumer reads the supplied set - * synchronously during the SSR pass (no fetch round-trip required). - */ -function buildClient(flags: Partial): QueryClient { - const client = new QueryClient({ - defaultOptions: { queries: { retry: 0 } }, - }); - client.setQueryData(["feature-flags"], { - assistant: { - enabled: true, - coach: true, - briefing: true, - insightStatus: true, - documentAi: true, - ...flags, - }, - }); - return client; -} - -function renderWithFlags(node: React.ReactNode, coach: boolean): string { - // v1.4.38 W-C M-5 — drive the mocked `useFeatureFlags` from the same - // matrix the QueryClient is seeded with so the gate-call assertion at - // the bottom of the file can read the resolved value. Keeping the - // QueryClient seed in place preserves the original render path for - // any consumer that bypasses the hook. - const resolved: AssistantFlagSet = { - ...DEFAULT_ASSISTANT_FLAGS, - coach, - }; - featureFlagsSpy.mockImplementation(() => resolved); - const client = buildClient({ coach }); - return renderToStaticMarkup( - - {node} - , - ); -} - -// ──────────────────────────────────────────────────────────────────── -// Coach-bearing surface fixture. -// -// Each entry mounts the surface in isolation, supplies the minimum -// non-Coach scaffolding the component needs (CoachLaunchProvider, …) -// and exposes a stable substring that proves the surface painted. The -// substring should be unique enough that a "did not render" check is -// meaningful — typically the surface's `data-slot="coach-*"` -// attribute or its localised label. -// -// IMPORTANT: when adding a new Coach surface to the codebase, add a -// matching entry here. The test below walks every entry and asserts -// the surface vanishes when the operator's global Coach flag is off. -// ──────────────────────────────────────────────────────────────────── - -interface CoachSurface { - /** Human-readable description that surfaces in the test output. */ - name: string; - /** Mounts the surface in isolation. Receives no props. */ - mount: () => React.ReactNode; - /** - * Substring that proves the surface painted when the flag is ON. - * Empty string means "the surface's SSR shape is intentionally - * blank even with the flag on" (e.g. lazy-loaded subtrees); the - * positive-case test is skipped while the negative-case - * `coach-*` slot grep still pins the gate. - */ - proofWhenOn: string; -} - -// v1.18.7 — the HeroStrip "Ask the coach" action button + the in-band -// SuggestedPrompts chip strip were removed from the overview hero, and the -// strip's component has since been deleted along with the rest of the -// unmounted insights cluster. The Coach is the bottom-right drawer; the -// launcher surfaces below carry the flag cascade. -const COACH_SURFACES: CoachSurface[] = [ - { - name: "CoachLaunchButton inline pill", - mount: () => ( - // The button only renders inside ; wrap so - // the Coach-flag-off branch is the dispositive guard. - - - - ), - proofWhenOn: 'data-slot="coach-launch-inline"', - }, - { - name: "LayoutCoachFab sticky mobile button", - mount: () => ( - - - - ), - // v1.16.1 — the FAB is nudge-driven: it renders nothing until the - // nudge-status query resolves with an unseen nudge, so its SSR - // shape is empty even when the flag is on. The negative `coach-*` - // grep + the gate-fired spy assertion still pin the off state. - proofWhenOn: "", - }, - { - name: "LayoutCoachMount drawer subtree", - mount: () => ( - - - - ), - // The drawer is lazy-loaded via next/dynamic and SSRs to nothing - // even when the flag is on; the contract here is "no Coach-marked - // mount point ever paints when the flag is off". Skip the proof - // check; the negative `coach-*` slot grep still pins the gate, and - // the explicit spy assertion in `LayoutCoachMount SSR-proof spy` - // below proves the gate actually fired during the off render. - proofWhenOn: "", - }, -]; - -describe("Coach disable cascade invariant", () => { - // Silence the dev-only "should not be used during SSR" warning some - // components emit when their effects would normally mount. - beforeEach(() => { - vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(console, "warn").mockImplementation(() => undefined); - // v1.4.38 W-C M-5 — reset the call history so each iteration of - // the surface loop sees a clean slate when it asserts the gate - // fired during the off render. - featureFlagsSpy.mockClear(); - featureFlagsSpy.mockImplementation(() => DEFAULT_ASSISTANT_FLAGS); - }); - - for (const surface of COACH_SURFACES) { - it(`${surface.name} — paints when the Coach flag is on`, () => { - // Skip the positive proof for surfaces whose SSR shape is - // intentionally empty (lazy-loaded drawer subtrees). The - // negative assertion below still pins the gate. - if (!surface.proofWhenOn) return; - const html = renderWithFlags(surface.mount(), true); - expect(html).toContain(surface.proofWhenOn); - }); - - it(`${surface.name} — vanishes when the Coach flag is off`, () => { - const html = renderWithFlags(surface.mount(), false); - if (surface.proofWhenOn) { - expect(html).not.toContain(surface.proofWhenOn); - } - // Every Coach-bearing surface in the app marks its mount point - // with a `coach-` slot. Pin the negative shape against the - // shared prefix so a surface that grows a new `coach-*` slot - // without a gate fails this test by default. - expect(html).not.toMatch(/data-slot="coach-[a-z]/); - // The suggested-prompts strip uses an `insights-suggested-prompts` - // slot (legacy naming pre-dates the `coach-` convention); pin - // it explicitly so the gate can't regress. - expect(html).not.toContain('data-slot="insights-suggested-prompts"'); - // Same story for the hero strip's coach action button + prompts - // wrapper. - expect(html).not.toContain( - 'data-slot="insights-hero-strip-action-coach"', - ); - expect(html).not.toContain('data-slot="insights-hero-strip-prompts"'); - // v1.4.38 W-C M-5 — `proofWhenOn: ""` surfaces (the lazy-loaded - // `LayoutCoachMount` drawer subtree) trivially pass the substring - // grep because their SSR shape is empty even when the gate is on. - // The spy assertion below proves the gate actually fired with the - // operator-disabled matrix, so a regression that removes the - // `if (!flags.coach) return null` short-circuit fails this test - // instead of slipping through under cover of the empty SSR shape. - expect(featureFlagsSpy).toHaveBeenCalled(); - const lastCall = featureFlagsSpy.mock.results.at(-1); - expect(lastCall?.value.coach).toBe(false); - }); - } - - // ────────────────────────────────────────────────────────────────── - // v1.4.38 W-C H4 — grep-based discovery of every `flags.coach` - // call site in the codebase. - // - // The fixture-count check below pins the surfaces the fixture mounts - // directly. A future contributor who lands a new Coach mount on (e.g.) - // `/insights/blood-pressure/page.tsx` would not trip the surface-count - // check at all — the new site is on a sub-page and would silently leak. - // - // The walk below scans `src/` for every `flags.coach` occurrence and - // requires each path to appear in the explicit `KNOWN_COACH_GATE_SITES` - // allowlist. Add a new entry every time you intentionally gate a new - // render path on the Coach flag; the failure message names the - // unaccounted-for paths so the fix is one search-and-add. - // ────────────────────────────────────────────────────────────────── - - /** - * Every file in `src/` that legitimately reads `flags.coach`. Lives - * here (next to the cascade fixture) so a contributor extending the - * fixture sees the allowlist in the same review. - * - * Paths are POSIX-style relative to the repo root. - */ - const KNOWN_COACH_GATE_SITES: ReadonlyArray = [ - // Coach-bearing surfaces mounted directly by the cascade fixture. - "src/components/insights/coach-launch-button.tsx", - // v1.21.0 (C4 H2) — the reverse-direction "Ask the Coach about this" - // card affordance gates on `flags.coach` (operator master) like the - // launch button, returning null when the operator turns the Coach off. - "src/components/insights/ask-coach-action.tsx", - // v1.18.7 — hero-strip.tsx no longer reads `flags.coach`: its coach - // action button + suggested-prompt strip were removed from the band. - "src/components/insights/layout-coach-fab.tsx", - "src/components/insights/layout-coach-mount.tsx", - // v1.12.0 — the full-page Coach route gates on `flags.coach` and - // redirects to `/insights` when the operator master flag is off. - // v1.18.0 — moved to the standalone top-level `/coach` route. - "src/app/coach/page-client.tsx", - // v1.21.4 — the dedicated conversation-history page gates on - // `flags.coach` like the main Coach route and renders unavailable - // when the operator master flag is off. - "src/app/coach/conversations/page.tsx", - // The plans management page mirrors the conversation-history page's - // gate: operator master flag off redirects back to `/insights`. - "src/app/coach/plans/page.tsx", - // v1.15.20 — the proactive Coach-nudge cron short-circuits on - // `flags.coach` (gate 1) so an operator kill-switch also silences - // the nudges. Server-side cron, not a render path — the cascade - // fixture cannot mount it, so the allowlist entry is the pin. - "src/lib/jobs/coach-nudge.ts", - // The Modules hub renders a live Coach on/off switch. It reads - // `flags.coach` (the operator assistant master flag) so the switch goes - // disabled + "disabled server-wide" when the operator killed Coach — a - // per-user toggle cannot re-enable it. Distinct from the render-path - // gates above: here the flag drives the switch's disabled state, not a - // null-return. - "src/components/settings/modules-section.tsx", - ]; - - /** - * Walk every TS / TSX file under `src/`, excluding test trees, and - * collect the relative paths that contain a `flags.coach` token. The - * grep is intentionally simple — any literal occurrence counts so a - * contributor who paraphrases the read (`const c = flags.coach`) - * still trips this test. - */ - function findCoachFlagSites(): string[] { - const srcRoot = resolve(__dirname, "..", "..", ".."); - const hits: string[] = []; - const repoRoot = resolve(srcRoot, ".."); - - function walk(dir: string): void { - const entries = readdirSync(dir); - for (const entry of entries) { - const full = join(dir, entry); - const stat = statSync(full); - if (stat.isDirectory()) { - if (entry === "node_modules" || entry === ".next") continue; - // Skip every `__tests__/` subtree — the discovery target is - // the production gate, not the assertions about it. - if (entry === "__tests__") continue; - // v1.4.47 W3 — skip the Prisma client output. The generator - // embeds the entire `prisma/schema.prisma` source into - // `internal/class.ts` as a JSON-escaped `inlineSchema` - // string, so any literal `flags.coach` mention in a schema - // doc-comment leaks through here and trips the orphan check. - // Generated derivatives are never the gate site we want to - // pin; the production component file is. - if (entry === "generated") continue; - walk(full); - continue; - } - if (!/\.(tsx|ts)$/.test(entry)) continue; - if (/\.test\.(tsx|ts)$/.test(entry)) continue; - const text = readFileSync(full, "utf8"); - // Match `flags.coach` on lines that are NOT pure comments. A - // hit on a JSDoc / `//` line is documentation, not a gate, and - // would make the allowlist a maintenance burden every time - // someone references the gate in a comment. - const isRealGate = text.split("\n").some((line) => { - if (!line.includes("flags.coach")) return false; - const trimmed = line.trim(); - if (trimmed.startsWith("//")) return false; - if (trimmed.startsWith("*")) return false; - return true; - }); - if (isRealGate) { - // POSIX-style path relative to the repo root so the failure - // message stays portable across contributors' checkout - // directories. - hits.push(relative(repoRoot, full).split(/[\\/]/).join("/")); - } - } - } - - walk(srcRoot); - return hits.sort(); - } - - it("grep-based discovery pins every `flags.coach` call site", () => { - const discovered = findCoachFlagSites(); - const allowlist = new Set(KNOWN_COACH_GATE_SITES); - const orphans = discovered.filter((path) => !allowlist.has(path)); - expect( - orphans, - [ - "Found `flags.coach` in file(s) not on the KNOWN_COACH_GATE_SITES", - "allowlist (src/lib/feature-flags/__tests__/coach-cascade.test.tsx).", - "Add a fixture entry (or a sibling invariant) for the new gate,", - "then add the path to the allowlist. Orphans:", - ...orphans.map((p) => ` - ${p}`), - ].join("\n"), - ).toEqual([]); - - // Sanity check the other direction — an allowlist entry that no - // longer references `flags.coach` is dead documentation and - // should be pruned in the same PR that removes the gate. - const discoveredSet = new Set(discovered); - const stale = KNOWN_COACH_GATE_SITES.filter( - (path) => !discoveredSet.has(path), - ); - expect( - stale, - [ - "KNOWN_COACH_GATE_SITES references file(s) that no longer use", - "`flags.coach`. Remove the stale allowlist entry:", - ...stale.map((p) => ` - ${p}`), - ].join("\n"), - ).toEqual([]); - }); - - it("fixture stays in sync with the Coach gate call sites", () => { - // Counts the Coach-bearing surfaces the fixture knows about. If a - // contributor adds a new `useFeatureFlags()` + `flags.coach` gate - // to a render path, the count below must move and this assertion - // forces them to revisit the fixture. The number tracks the - // surfaces the fixture mounts directly; the grep-based discovery - // test above pins every other `flags.coach` call site. - // v1.18.7 — dropped from 6 to 4: the two HeroStrip coach surfaces - // (action button + suggested-prompt strip) were removed from the band. - expect(COACH_SURFACES.length).toBe(3); - }); -}); diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx deleted file mode 100644 index 360ded7c4..000000000 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ /dev/null @@ -1,431 +0,0 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join, relative, resolve } from "node:path"; - -import React from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; -import { - DEFAULT_ASSISTANT_FLAGS, - type AssistantFlagSet, -} from "@/hooks/use-feature-flags"; -import type { AuthUser } from "@/hooks/use-auth"; - -import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; -import { LayoutCoachFab } from "@/components/insights/layout-coach-fab"; -import { LayoutCoachMount } from "@/components/insights/layout-coach-mount"; - -/** - * v1.4.47 W3 — per-user "Hide Coach" opt-out invariant. - * - * The Coach disable cascade (`coach-cascade.test.tsx`) pins the - * operator-level `flags.coach` gate. This sibling fixture pins the - * per-user `disableCoach` gate that sits BELOW the operator flag — - * either gate being off must hide every Coach affordance with no - * grey-out, no error, no inert button, no DOM trace. - * - * Mocking strategy mirrors the cascade fixture: spy `useFeatureFlags` - * to drive the operator matrix synchronously during SSR, spy - * `useAuth` to drive the per-user `disableCoach` field. Both spies - * are reset between iterations so a regression in either gate trips - * the invariant in isolation. - */ - -const featureFlagsSpy = vi.fn<() => AssistantFlagSet>( - () => DEFAULT_ASSISTANT_FLAGS, -); -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: () => featureFlagsSpy(), - }; -}); - -const authSpy = vi.fn< - () => { user: AuthUser | null; isAuthenticated: boolean } ->(() => ({ user: null, isAuthenticated: false })); -vi.mock("@/hooks/use-auth", async () => { - const actual = - await vi.importActual( - "@/hooks/use-auth", - ); - return { - ...actual, - useAuth: () => authSpy(), - }; -}); - -// The Coach gates read user.disableCoach through `useDisableCoach`, -// which itself calls `useAuth()`. Mocking `useDisableCoach` directly -// would bypass the useAuth call site and make the spy assertion -// below trivially fail; instead we let the real `useDisableCoach` -// pass through and capture the underlying `useAuth` calls. - -// v1.16.1 — the nudge-driven `` reads the app router -// (push + pathname); SSR test renders have no router context. -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/insights", -})); - -function buildUser(disableCoach: boolean): AuthUser { - // Minimal `AuthUser` shape — the Coach gates only read - // `disableCoach`, but TypeScript requires the full interface to - // satisfy the mock's return type. Everything else is filler. - return { - id: "test-user-id", - username: "test", - email: null, - role: "USER", - heightCm: null, - dateOfBirth: null, - gender: null, - timezone: "Europe/Berlin", - onboardingCompletedAt: null, - onboardingTourCompleted: true, - onboardingTourProgress: null, - avatarUrl: null, - glucoseUnit: null, - unitPreference: "metric", - timeFormat: "AUTO", - dateFormat: "AUTO", - disableCoach, - fullName: null, - insurerName: null, - insurerIkNumber: null, - insuranceNumber: null, - lastReportPracticeName: null, - reportSelection: null, - cycleTrackingEnabled: false, - modules: {}, - }; -} - -function buildClient(): QueryClient { - return new QueryClient({ - defaultOptions: { queries: { retry: 0 } }, - }); -} - -function renderWithDisableCoach( - node: React.ReactNode, - disableCoach: boolean, - coach: boolean = true, -): string { - featureFlagsSpy.mockImplementation(() => ({ - ...DEFAULT_ASSISTANT_FLAGS, - coach, - })); - authSpy.mockImplementation(() => ({ - user: buildUser(disableCoach), - isAuthenticated: true, - })); - const client = buildClient(); - return renderToStaticMarkup( - - {node} - , - ); -} - -interface DisableCoachSurface { - name: string; - mount: () => React.ReactNode; - /** - * Substring that proves the surface painted. Empty string means - * "the surface SSR-renders to nothing even when on" (lazy-loaded - * subtrees) and the negative assertion is the dispositive check. - */ - proofWhenVisible: string; -} - -const DISABLE_COACH_SURFACES: DisableCoachSurface[] = [ - { - name: "LayoutCoachFab", - mount: () => ( - - - - ), - // v1.16.1 — nudge-driven: SSR shape is empty even when visible - // (the bubble waits for the nudge-status query). Negative - // assertion still pins the gate via the `coach-*` slot grep. - proofWhenVisible: "", - }, - { - name: "LayoutCoachMount", - mount: () => ( - - - - ), - // Lazy-loaded — SSR shape is empty even when visible. Negative - // assertion still pins the gate via the `coach-*` slot grep. - proofWhenVisible: "", - }, - { - name: "CoachLaunchButton inline pill", - mount: () => ( - - - - ), - proofWhenVisible: 'data-slot="coach-launch-inline"', - }, - // v1.18.7 — the HeroStrip "Ask the coach" action button and the - // SuggestedPrompts chip strip were removed from the overview hero, and the - // strip's component has now been deleted with the rest of the unmounted - // insights cluster. The Coach is the bottom-right drawer; the launcher - // surfaces above carry the per-user disable gate. -]; - -describe("Coach per-user disableCoach invariant", () => { - beforeEach(() => { - vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(console, "warn").mockImplementation(() => undefined); - featureFlagsSpy.mockClear(); - authSpy.mockClear(); - featureFlagsSpy.mockImplementation(() => DEFAULT_ASSISTANT_FLAGS); - authSpy.mockImplementation(() => ({ - user: buildUser(false), - isAuthenticated: true, - })); - }); - - for (const surface of DISABLE_COACH_SURFACES) { - it(`${surface.name} — paints when user.disableCoach is false`, () => { - if (!surface.proofWhenVisible) return; - const html = renderWithDisableCoach(surface.mount(), false); - expect(html).toContain(surface.proofWhenVisible); - }); - - it(`${surface.name} — vanishes when user.disableCoach is true`, () => { - const html = renderWithDisableCoach(surface.mount(), true); - if (surface.proofWhenVisible) { - expect(html).not.toContain(surface.proofWhenVisible); - } - // No Coach-marked mount point may paint when the per-user gate - // is on. Pin the negative shape against the shared prefix so a - // future surface that grows a new `coach-*` slot without the - // disableCoach gate fails this test by default. - expect(html).not.toMatch(/data-slot="coach-[a-z]/); - expect(html).not.toContain('data-slot="insights-suggested-prompts"'); - expect(html).not.toContain( - 'data-slot="insights-hero-strip-action-coach"', - ); - expect(html).not.toContain('data-slot="insights-hero-strip-prompts"'); - }); - - it(`${surface.name} — also vanishes when flags.coach is false (regression pin)`, () => { - // The legacy operator-level gate must keep working in the - // presence of the new per-user gate. This guards against a - // regression where a contributor "simplifies" the two checks - // into one and accidentally drops the operator branch. - const html = renderWithDisableCoach(surface.mount(), false, false); - if (surface.proofWhenVisible) { - expect(html).not.toContain(surface.proofWhenVisible); - } - expect(html).not.toMatch(/data-slot="coach-[a-z]/); - }); - } - - it("`useAuth` gate is consulted on every Coach surface", () => { - // A `disableCoach: true` render must trip the `useAuth` spy at - // least once. Otherwise a future regression that drops the - // `useAuth()` call site silently leaks the affordance. - for (const surface of DISABLE_COACH_SURFACES) { - authSpy.mockClear(); - renderWithDisableCoach(surface.mount(), true); - expect( - authSpy, - `${surface.name} did not consult useAuth() during render`, - ).toHaveBeenCalled(); - } - }); - - // ────────────────────────────────────────────────────────────────── - // v1.4.48 H2 — grep-based discovery of every `useDisableCoach` / - // `user.disableCoach` call site in the codebase. - // - // Mirrors the `flags.coach` discovery walk in `coach-cascade.test.tsx`: - // the surface fixture above pins the surfaces this file mounts - // directly. Cross-cut gates (Settings → Insights toggle, `/api/auth/me` - // response shape, the disable-coach PATCH route) sit OUTSIDE the - // fixture and are owned by sibling invariants or by route-level tests. - // - // The walk below scans `src/` for every `useDisableCoach` / - // `user.disableCoach` / `user?.disableCoach` occurrence and requires - // each path to appear in the explicit `KNOWN_DISABLE_COACH_GATE_SITES` - // allowlist. Add a new entry every time you intentionally read the - // per-user disable flag in a new path; the failure message names the - // unaccounted-for paths so the fix is one search-and-add. - // ────────────────────────────────────────────────────────────────── - - /** - * Every file in `src/` that legitimately reads `useDisableCoach` or - * `user.disableCoach`. Lives next to the disable-cascade fixture so a - * contributor extending the fixture sees the allowlist in the same - * review. - * - * Paths are POSIX-style relative to the repo root. - */ - const KNOWN_DISABLE_COACH_GATE_SITES: ReadonlyArray = [ - // Coach-bearing surfaces mounted directly by the disable-cascade - // fixture above; each one calls `useDisableCoach()` and short- - // circuits to `null` when the per-user flag is on. - "src/components/insights/coach-launch-button.tsx", - // v1.21.0 (C4 H2) — the reverse-direction "Ask the Coach about this" - // card affordance mirrors the launch-button gate: operator master flag - // OR per-user opt-out short-circuits it to `null` so a card never paints - // a dead Coach control. - "src/components/insights/ask-coach-action.tsx", - // v1.18.7 — hero-strip.tsx no longer gates on the Coach flag: its - // action button + suggested-prompt strip were removed, so it dropped - // the `useDisableCoach` call entirely. - "src/components/insights/layout-coach-fab.tsx", - "src/components/insights/layout-coach-mount.tsx", - // v1.12.0 — the full-page Coach route mirrors the launch-button / - // FAB gate: operator master flag OR per-user opt-out redirects the - // page back to `/insights` instead of painting a dead chat shell. - // v1.18.0 — moved to the standalone top-level `/coach` route. - // v1.30.x — split into an RSC prefetch wrapper (`page.tsx`) + the client - // leaf (`page-client.tsx`, the `useDisableCoach` render-path gate). The - // RSC wrapper now asks the `coach` capability, which folds the opt-out in, - // so only the client leaf reads the flag directly. - "src/app/coach/page-client.tsx", - // v1.21.4 — the dedicated conversation-history page mirrors the - // Coach route gate: operator master flag OR per-user opt-out marks - // the page unavailable instead of painting a dead history shell. - "src/app/coach/conversations/page.tsx", - // The plans management page mirrors the same gate: operator master - // flag OR per-user opt-out redirects instead of a dead plans shell. - "src/app/coach/plans/page.tsx", - // The hook itself + its `useAuth`-backed reader. - "src/hooks/use-disable-coach.ts", - // Cross-cut gates owned by sibling invariants / route tests. - // - `/api/auth/me` ships the flag in the session payload so the - // client hooks can read it (`useAuth → useDisableCoach`). The - // sibling `/api/auth/me/disable-coach` PATCH endpoint that - // writes the flag does NOT read `user.disableCoach` (it reads - // `body.disableCoach`) and is intentionally not listed here. - "src/app/api/auth/me/route.ts", - // - Settings → Coach section: the toggle card reads + writes the - // flag; the section shell reads it to gate the Coach sub-cards. - // v1.18.0 — moved out of the AI/Assistent section into its own - // dedicated Coach settings entry. - "src/components/settings/ai/disable-coach-card.tsx", - "src/components/settings/coach-section.tsx", - // The dashboard snapshot no longer reads the flag: hiding the Coach - // hides the Coach, and the daily briefing follows its own `briefing` - // capability, applied per read in `snapshot-read.ts`. - // v1.18.0 — the module enable/disable gate delegates the `coach` - // module to the SAME two-layer source of truth (`user.disableCoach` - // AND the operator assistant master flag) rather than owning a - // second copy in `modulePreferencesJson`. Covered by the coach- - // delegation cases in `src/lib/modules/__tests__/gate.test.ts`. - "src/lib/modules/gate.ts", - // The Modules hub Coach row renders a live switch bound to the SAME - // `user.disableCoach` source of truth: `checked` is `!disableCoach` and a - // flip writes the inverted flag via `PATCH /api/auth/me/disable-coach`. - // Reads the flag off `useAuth().user` (no second copy). Covered by the - // delegated-coach cases in - // `src/components/settings/__tests__/modules-section.test.tsx`. - "src/components/settings/modules-section.tsx", - ]; - - /** - * Walk every TS / TSX file under `src/`, excluding test trees + the - * Prisma client output, and collect the relative paths that - * legitimately reference `useDisableCoach` or `user.disableCoach`. - * Any literal occurrence on a non-comment line counts so a - * contributor who paraphrases the read still trips this test. - */ - function findDisableCoachSites(): string[] { - const srcRoot = resolve(__dirname, "..", "..", ".."); - const repoRoot = resolve(srcRoot, ".."); - const hits: string[] = []; - - function isRealGateLine(line: string): boolean { - // Trim once so leading whitespace doesn't fool the comment check. - const trimmed = line.trim(); - if (trimmed.startsWith("//")) return false; - if (trimmed.startsWith("*")) return false; - // `useDisableCoach` covers both the hook import + the hook call. - if (line.includes("useDisableCoach")) return true; - // `user.disableCoach` / `user?.disableCoach` — the canonical - // per-user gate read on the auth user object. Strict match on - // `user` so we don't grab unrelated `disableCoach` strings (route - // body keys, i18n message ids, …). - if (/\buser\??\.disableCoach\b/.test(line)) return true; - return false; - } - - function walk(dir: string): void { - const entries = readdirSync(dir); - for (const entry of entries) { - const full = join(dir, entry); - const stat = statSync(full); - if (stat.isDirectory()) { - if (entry === "node_modules" || entry === ".next") continue; - if (entry === "__tests__") continue; - // Skip the Prisma client output — the generator embeds the - // entire `prisma/schema.prisma` source as a JSON-escaped - // `inlineSchema` string and any schema doc-comment that - // mentions `disableCoach` would otherwise leak through here. - if (entry === "generated") continue; - walk(full); - continue; - } - if (!/\.(tsx|ts)$/.test(entry)) continue; - if (/\.test\.(tsx|ts)$/.test(entry)) continue; - const text = readFileSync(full, "utf8"); - if (text.split("\n").some(isRealGateLine)) { - hits.push(relative(repoRoot, full).split(/[\\/]/).join("/")); - } - } - } - - walk(srcRoot); - return hits.sort(); - } - - it("grep-based discovery pins every `useDisableCoach` / `user.disableCoach` call site", () => { - const discovered = findDisableCoachSites(); - const allowlist = new Set(KNOWN_DISABLE_COACH_GATE_SITES); - const orphans = discovered.filter((path) => !allowlist.has(path)); - expect( - orphans, - [ - "Found `useDisableCoach` / `user.disableCoach` in file(s) not on", - "the KNOWN_DISABLE_COACH_GATE_SITES allowlist", - "(src/lib/feature-flags/__tests__/coach-user-disable.test.tsx).", - "Add a fixture entry (or a sibling invariant) for the new gate,", - "then add the path to the allowlist. Orphans:", - ...orphans.map((p) => ` - ${p}`), - ].join("\n"), - ).toEqual([]); - - // Sanity check the other direction — an allowlist entry that no - // longer references the gate is dead documentation and should be - // pruned in the same PR that removes the read. - const discoveredSet = new Set(discovered); - const stale = KNOWN_DISABLE_COACH_GATE_SITES.filter( - (path) => !discoveredSet.has(path), - ); - expect( - stale, - [ - "KNOWN_DISABLE_COACH_GATE_SITES references file(s) that no longer", - "use `useDisableCoach` / `user.disableCoach`. Remove the stale", - "allowlist entry:", - ...stale.map((p) => ` - ${p}`), - ].join("\n"), - ).toEqual([]); - }); -});