feat(coach): follow the coach capability at every entry point

The floating button, the drawer, the launch pills, the nav entry and the
Coach pages read the coach capability from /api/auth/me instead of
combining the operator switch with Hide Coach on their own. A missing
provider or consent now hides the Coach too, instead of opening one that
can only answer with an error.

Stored conversations stay reachable: while the Coach is unavailable the
conversation list turns read-only, opens a transcript in place and keeps
delete. Refusals before the stream opens are read by meta.errorCode and
each code has its own copy.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 11:05:38 +02:00
1 parent c2005fd8f1
commit 61d912627d
22 files changed
+371 -986

No files matched your search

@@ -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 }),
@@ -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 }),
+47 -36
View File
@@ -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<string | null>(null);
const router = useRouter();
const [filter, setFilter] = useState<string>("");
// 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=<id>` open mechanism on the Coach page.
router.push(`/coach?c=${id}`);
}
@@ -175,18 +183,24 @@ function CoachConversationsBody() {
{t("insights.coach.historyTitle")}
</span>
}
description={
readOnly ? t("insights.coach.historyReadOnlyNote") : undefined
}
actions={
// Surface the sibling Plans ledger — otherwise it is reachable
// only from the composer's `+` menu.
<Button asChild variant="outline" size="sm">
<Link
href="/coach/plans"
data-slot="coach-conversations-plans-link"
>
<Target className="size-4" aria-hidden="true" />
{t("coach.plans.title")}
</Link>
</Button>
// only from the composer's `+` menu. Plans need the Coach, so the
// link stays away while it is unavailable.
readOnly ? undefined : (
<Button asChild variant="outline" size="sm">
<Link
href="/coach/plans"
data-slot="coach-conversations-plans-link"
>
<Target className="size-4" aria-hidden="true" />
{t("coach.plans.title")}
</Link>
</Button>
)
}
/>
<div className="relative">
@@ -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}
>
<span className="flex min-w-0 items-center gap-1.5 font-medium">
{/* v1.29.x (S7) — badge fenced threads (they run on
@@ -294,7 +309,9 @@ function CoachConversationsBody() {
{formatRelativeTime(c.updatedAt, t, locale)}
</span>
</button>
<ConversationRename id={c.id} title={c.title} />
{readOnly ? null : (
<ConversationRename id={c.id} title={c.title} />
)}
<Button
type="button"
variant="ghost"
@@ -306,6 +323,11 @@ function CoachConversationsBody() {
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
{readOnly && openId === c.id ? (
<div className="basis-full">
<ConversationTranscript id={c.id} />
</div>
) : null}
</li>
);
})}
@@ -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 (
<div
@@ -372,7 +383,7 @@ export default function CoachConversationsPage() {
// ~50px on notched devices; restored to match exactly.
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
>
<CoachConversationsBody />
<CoachConversationsBody readOnly={!coach.available} />
</div>
);
}
+12 -11
View File
@@ -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 `<CoachLaunchButton>` / `<LayoutCoachFab>`:
// 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
+7 -12
View File
@@ -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 (
<div
@@ -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");
});
}
}
});
@@ -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,
+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 -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}
+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)
-34
View File
@@ -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 `<QueryClientProvider>` (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 (`<SuggestedPrompts>`, `<LayoutCoachFab>`,
* `<HeroStrip>` …) render the component in isolation and don't.
*
* v1.4.48 M4 — share the `<QueryClientProvider>`-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;
}
+1 -1
View File
@@ -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 (`<HeroStrip>`, the
* isolated score-card snapshots, …) renders without a
* `<QueryClientProvider>`. We gate the inner hook on
+32
View File
@@ -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],
);
}
@@ -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 `<LayoutCoachFab>` 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<AssistantFlagSet>): 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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
// ────────────────────────────────────────────────────────────────────
// 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 <CoachLaunchProvider>; wrap so
// the Coach-flag-off branch is the dispositive guard.
<CoachLaunchProvider>
<CoachLaunchButton />
</CoachLaunchProvider>
),
proofWhenOn: 'data-slot="coach-launch-inline"',
},
{
name: "LayoutCoachFab sticky mobile button",
mount: () => (
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>
),
// 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: () => (
<CoachLaunchProvider>
<LayoutCoachMount />
</CoachLaunchProvider>
),
// 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<string> = [
// 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);
});
});
@@ -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<typeof import("@/hooks/use-auth")>(
"@/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 `<LayoutCoachFab>` 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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
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: () => (
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>
),
// 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: () => (
<CoachLaunchProvider>
<LayoutCoachMount />
</CoachLaunchProvider>
),
// 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: () => (
<CoachLaunchProvider>
<CoachLaunchButton />
</CoachLaunchProvider>
),
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<string> = [
// 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([]);
});
});