", () => {
+ it("renders the coach copy in the card variant", () => {
+ const html = render();
+ expect(html).toContain("data-slot=\"assistant-disabled-notice\"");
+ expect(html.toLowerCase()).toContain("coach");
+ });
+
+ it("renders the briefing copy", () => {
+ const html = render();
+ expect(html).toContain("data-slot=\"assistant-disabled-notice\"");
+ });
+
+ it("renders the status copy", () => {
+ const html = render();
+ expect(html).toContain("data-slot=\"assistant-disabled-notice\"");
+ });
+
+ it("renders the inline variant without the Card wrapper", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain("` returns `null` when
+ * the operator has disabled the `insightStatus` surface. Renders
+ * normally otherwise.
+ */
+function wrap(node: React.ReactNode, client: QueryClient) {
+ return renderToStaticMarkup(
+
+ {node}
+ ,
+ );
+}
+
+const baseProps = {
+ title: "Pulse",
+ icon: null,
+ text: "Your pulse is stable.",
+ hasProvider: true,
+ cached: false,
+ updatedAt: null,
+};
+
+function makeClient(flags: {
+ enabled: boolean;
+ coach: boolean;
+ briefing: boolean;
+ insightStatus: boolean;
+ correlations: boolean;
+ healthScoreExplainer: boolean;
+}): QueryClient {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: 0 } },
+ });
+ client.setQueryData(["feature-flags"], { assistant: flags });
+ return client;
+}
+
+describe(" — assistant.insightStatus gate", () => {
+ it("renders the card when the flag is on", () => {
+ const html = wrap(
+ ,
+ makeClient({
+ enabled: true,
+ coach: true,
+ briefing: true,
+ insightStatus: true,
+ correlations: true,
+ healthScoreExplainer: true,
+ }),
+ );
+ expect(html).toContain("Your pulse is stable");
+ });
+
+ it("returns null when the operator disables the sub-flag", () => {
+ const html = wrap(
+ ,
+ makeClient({
+ enabled: true,
+ coach: true,
+ briefing: true,
+ insightStatus: false,
+ correlations: true,
+ healthScoreExplainer: true,
+ }),
+ );
+ expect(html).toBe("");
+ });
+
+ it("returns null when the master flag is off", () => {
+ const html = wrap(
+ ,
+ makeClient({
+ enabled: false,
+ coach: false,
+ briefing: false,
+ insightStatus: false,
+ correlations: false,
+ healthScoreExplainer: false,
+ }),
+ );
+ expect(html).toBe("");
+ });
+});
diff --git a/src/components/insights/assistant-disabled-notice.tsx b/src/components/insights/assistant-disabled-notice.tsx
new file mode 100644
index 000000000..7d2bece99
--- /dev/null
+++ b/src/components/insights/assistant-disabled-notice.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { Sparkles } from "lucide-react";
+
+import { Card, CardContent } from "@/components/ui/card";
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+
+/**
+ * v1.4.31 — Small inline notice surfaced when an assistant surface
+ * is gated off by the operator. Used by callers that already
+ * resolved a `errorCode: "assistant.disabled."` response
+ * from the server or that detected the matching flag locally via
+ * `useFeatureFlags()`.
+ *
+ * The copy intentionally stays generic — "currently unavailable on
+ * this server" — so the surface doesn't leak operator-identity
+ * info into a user-facing string. Marc-voice English; the locale
+ * key carries the surface-specific phrasing.
+ */
+export type AssistantDisabledSurface =
+ | "coach"
+ | "briefing"
+ | "insightStatus"
+ | "correlations";
+
+interface AssistantDisabledNoticeProps {
+ surface: AssistantDisabledSurface;
+ className?: string;
+ variant?: "card" | "inline";
+}
+
+const SURFACE_LABEL_KEY: Record = {
+ coach: "insights.coach.disabledByOperator",
+ briefing: "insights.briefing.disabledByOperator",
+ insightStatus: "insights.status.disabledByOperator",
+ correlations: "insights.correlations.disabledByOperator",
+};
+
+export function AssistantDisabledNotice({
+ surface,
+ className,
+ variant = "card",
+}: AssistantDisabledNoticeProps) {
+ const { t } = useTranslations();
+ const message = t(SURFACE_LABEL_KEY[surface]);
+
+ if (variant === "inline") {
+ return (
+
+ {message}
+
+ );
+ }
+
+ return (
+
+
+
+ {message}
+
+
+ );
+}
diff --git a/src/components/insights/coach-launch-button.tsx b/src/components/insights/coach-launch-button.tsx
index f559d097e..84c7dd6bc 100644
--- a/src/components/insights/coach-launch-button.tsx
+++ b/src/components/insights/coach-launch-button.tsx
@@ -6,6 +6,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";
/**
* Inline desktop Coach launch button.
@@ -40,12 +41,15 @@ export function CoachLaunchButton({
}: CoachLaunchButtonProps) {
const { t } = useTranslations();
const launch = useCoachLaunch();
+ const flags = useFeatureFlags();
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;
const accessibleLabel = label ?? t("insights.heroActionAskCoach");
diff --git a/src/components/insights/health-score-delta-explainer.tsx b/src/components/insights/health-score-delta-explainer.tsx
index 388747662..008e7c52e 100644
--- a/src/components/insights/health-score-delta-explainer.tsx
+++ b/src/components/insights/health-score-delta-explainer.tsx
@@ -9,6 +9,7 @@ import {
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useIsMobile } from "@/hooks/use-is-mobile";
+import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -61,12 +62,19 @@ export function HealthScoreDeltaExplainer({
}: HealthScoreDeltaExplainerProps) {
const { t } = useTranslations();
const isMobile = useIsMobile();
+ const flags = useFeatureFlags();
const [open, setOpen] = useState(false);
// Stable fallback id when the parent doesn't supply one. The body
// still paints the id so future consumers can thread the same
// describedby pattern without modifying the explainer.
const generatedId = useId();
const resolvedBodyId = bodyId ?? generatedId;
+ // v1.4.31 — operator can hide the `?` trigger; the delta digit on
+ // the parent line stays visible because the parent reads it
+ // directly. Silent suppression per the architecture brief. The
+ // early return sits below every hook call so the hook order stays
+ // stable across renders.
+ if (!flags.healthScoreExplainer) return null;
const triggerLabel = t("insights.healthScore.deltaExplainer.trigger");
const title = t("insights.healthScore.deltaExplainer.title");
diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx
index 7eeb943d2..993632730 100644
--- a/src/components/insights/insight-status-card.tsx
+++ b/src/components/insights/insight-status-card.tsx
@@ -7,6 +7,7 @@ import { Loader2 } from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { cn } from "@/lib/utils";
+import { useFeatureFlags } from "@/hooks/use-feature-flags";
// ─── Types ────────────────────────────────────────────────
@@ -32,6 +33,11 @@ export function InsightStatusCard({
loading = false,
}: InsightStatusCardProps) {
const { t } = useTranslations();
+ const flags = useFeatureFlags();
+ // v1.4.31 — operator can hide every per-metric status card
+ // app-wide. The delta number stays; the LLM narration card is
+ // suppressed in full so the layout collapses naturally.
+ if (!flags.insightStatus) return null;
if (loading) {
return (
diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx
index 0f8412b96..ba1fd7214 100644
--- a/src/components/insights/layout-coach-fab.tsx
+++ b/src/components/insights/layout-coach-fab.tsx
@@ -6,6 +6,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";
/**
* Layout-level mobile Coach launch FAB.
@@ -25,7 +26,11 @@ import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
export function LayoutCoachFab() {
const { t } = useTranslations();
const launch = useCoachLaunch();
+ const flags = useFeatureFlags();
if (!launch) return null;
+ // v1.4.31 — operator can hide the Coach surface app-wide. The
+ // FAB returns null when the master OR the coach sub-flag is off.
+ if (!flags.coach) return null;
const accessibleLabel = t("insights.heroActionAskCoach");
diff --git a/src/components/insights/layout-coach-mount.tsx b/src/components/insights/layout-coach-mount.tsx
index b93f953b5..442a27b7d 100644
--- a/src/components/insights/layout-coach-mount.tsx
+++ b/src/components/insights/layout-coach-mount.tsx
@@ -2,6 +2,7 @@
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
+import { useFeatureFlags } from "@/hooks/use-feature-flags";
/**
* v1.4.27 R3d MB4 — bridge between the layout-level
@@ -15,7 +16,11 @@ import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
*/
export function LayoutCoachMount() {
const launch = useCoachLaunch();
+ const flags = useFeatureFlags();
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;
return (
{JSON.stringify(flags)};
+}
+
+function htmlEscape(s: string): string {
+ return s.replace(/"/g, """);
+}
+
+describe("useFeatureFlags", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("returns the all-on default when no QueryClient is mounted", () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain(htmlEscape(JSON.stringify(DEFAULT_ASSISTANT_FLAGS)));
+ });
+
+ it("returns the all-on default while the query is still loading", () => {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: 0 } },
+ });
+ const html = renderToStaticMarkup(
+
+
+ ,
+ );
+ expect(html).toContain(htmlEscape(JSON.stringify(DEFAULT_ASSISTANT_FLAGS)));
+ });
+
+ it("returns the resolved matrix when the endpoint responds", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () =>
+ new Response(
+ JSON.stringify({
+ data: {
+ assistant: {
+ enabled: true,
+ coach: false,
+ briefing: true,
+ insightStatus: false,
+ correlations: true,
+ healthScoreExplainer: false,
+ },
+ },
+ }),
+ { status: 200 },
+ ),
+ ),
+ );
+
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: 0 } },
+ });
+ // Pre-populate so the SSR pass surfaces the cached payload
+ // synchronously without a fetch round-trip.
+ client.setQueryData(["feature-flags"], {
+ assistant: {
+ enabled: true,
+ coach: false,
+ briefing: true,
+ insightStatus: false,
+ correlations: true,
+ healthScoreExplainer: false,
+ },
+ });
+
+ const html = renderToStaticMarkup(
+
+
+ ,
+ );
+ expect(html).toContain(""coach":false");
+ expect(html).toContain(""insightStatus":false");
+ expect(html).toContain(""healthScoreExplainer":false");
+ });
+});
diff --git a/src/hooks/use-feature-flags.ts b/src/hooks/use-feature-flags.ts
new file mode 100644
index 000000000..2ac5d05ab
--- /dev/null
+++ b/src/hooks/use-feature-flags.ts
@@ -0,0 +1,99 @@
+"use client";
+
+import { useContext } from "react";
+import { QueryClientContext, useQuery } from "@tanstack/react-query";
+
+/**
+ * v1.4.31 — Client-side accessor for the operator's assistant
+ * feature-flag matrix. Mirrors `GET /api/feature-flags`'s response
+ * shape directly so callers can rely on a typed structure.
+ *
+ * The matrix gates every assistant-driven surface on the web —
+ * Coach + chat, Daily Briefing, per-metric status cards, correlation
+ * narration, Health-Score delta explainer. iOS reads the same
+ * endpoint and uses the same matrix; the contract is locked per
+ * `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
+ */
+
+export interface AssistantFlagSet {
+ /** Master kill-switch — when false, every sub-flag is forced false. */
+ enabled: boolean;
+ /** Coach drawer, chat SSE, history rail, FAB. */
+ coach: boolean;
+ /** Daily Briefing card + advisor recommendations + regen icon. */
+ briefing: boolean;
+ /** Per-metric status cards on every `/insights/` sub-page. */
+ insightStatus: boolean;
+ /** Correlation narration tile on the mother page. */
+ correlations: boolean;
+ /** `?` glyph that opens the Health-Score delta explainer popover. */
+ healthScoreExplainer: boolean;
+}
+
+interface FeatureFlagsPayload {
+ assistant: AssistantFlagSet;
+}
+
+/** All-on default — matches the v1.4.30 behaviour for fresh installs. */
+export const DEFAULT_ASSISTANT_FLAGS: AssistantFlagSet = Object.freeze({
+ enabled: true,
+ coach: true,
+ briefing: true,
+ insightStatus: true,
+ correlations: true,
+ healthScoreExplainer: true,
+});
+
+async function fetchFeatureFlags(): Promise {
+ const res = await fetch("/api/feature-flags");
+ if (!res.ok) {
+ // Soft-fail: any HTTP error returns the all-on default so the
+ // surface continues to render. The endpoint is a non-critical
+ // metadata read; a 5xx must not turn into a "Coach hidden"
+ // false negative for the user.
+ return { assistant: { ...DEFAULT_ASSISTANT_FLAGS } };
+ }
+ const json = (await res.json()) as { data: FeatureFlagsPayload };
+ return json.data;
+}
+
+/**
+ * Read the operator's assistant feature-flag matrix.
+ *
+ * `staleTime: 60_000` matches the server-side cache header so the
+ * hot /insights mount path doesn't re-fetch the matrix on every
+ * remount within a minute. The hook fails open — any network error
+ * returns the all-on default so the user keeps seeing the surface.
+ *
+ * The hook is referenced from a long tail of legacy presentational
+ * components (``, ``)
+ * whose unit tests render the component without a
+ * ``. To keep those tests valid we delegate
+ * the React-Query call to a child hook that's only invoked when a
+ * client is mounted. The branch is stable across the component's
+ * lifetime — `QueryClientProvider` placement never flips between
+ * renders — so the conditional hook call is safe per the Rules of
+ * Hooks (same posture the React docs document for "different
+ * environments where the same component renders").
+ */
+export function useFeatureFlags(): AssistantFlagSet {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const hasClient = useContext(QueryClientContext as unknown as React.Context);
+ if (!hasClient) return DEFAULT_ASSISTANT_FLAGS;
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ return useFeatureFlagsQuery();
+}
+
+function useFeatureFlagsQuery(): AssistantFlagSet {
+ const query = useQuery({
+ queryKey: ["feature-flags"],
+ queryFn: fetchFeatureFlags,
+ staleTime: 60_000,
+ gcTime: 300_000,
+ retry: 0,
+ });
+
+ // While loading or on error, default to all-on so the assistant
+ // surfaces don't flicker out and back in.
+ return query.data?.assistant ?? DEFAULT_ASSISTANT_FLAGS;
+}