mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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 }),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user