feat(insights): gate assistant surfaces on operator feature flags

New client hook `useFeatureFlags()` reads the matrix from
`/api/feature-flags` once per session and feeds every gated mount
on the Insights surface. The hook fails open — any network error
or missing `<QueryClientProvider>` (the legacy presentational test
posture) returns the all-on default so the user never loses an
affordance to an instrumentation gap.

Gated surfaces:

  <LayoutCoachFab>, <LayoutCoachMount>, <CoachLaunchButton>
                                                    → coach
  <DailyBriefing> on /insights/page.tsx             → briefing
  <CorrelationRow> on /insights/page.tsx            → correlations
  <InsightStatusCard> × 6 sub-pages                 → insightStatus
  <HealthScoreDeltaExplainer> ? glyph               → healthScoreExplainer

Master `assistant.enabled = false` forces every sub-flag false in
the resolver, so flipping the master collapses every surface at
once. Each component returns `null` when its flag is off; the
delta digit on the Health-Score card stays visible because the
parent reads the number directly.

New `<AssistantDisabledNotice>` component prepares the empty-state
copy for callers that detect a 403 + `errorCode:
"assistant.disabled.<surface>"` response. The localised strings
land in the dedicated i18n commit.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 13:54:00 +02:00
1 parent aeff47afde
commit 03d0b64bb8
11 files changed
+433 -8

No files matched your search

@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { AssistantDisabledNotice } from "../assistant-disabled-notice";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<AssistantDisabledNotice>", () => {
it("renders the coach copy in the card variant", () => {
const html = render(<AssistantDisabledNotice surface="coach" />);
expect(html).toContain("data-slot=\"assistant-disabled-notice\"");
expect(html.toLowerCase()).toContain("coach");
});
it("renders the briefing copy", () => {
const html = render(<AssistantDisabledNotice surface="briefing" />);
expect(html).toContain("data-slot=\"assistant-disabled-notice\"");
});
it("renders the status copy", () => {
const html = render(<AssistantDisabledNotice surface="insightStatus" />);
expect(html).toContain("data-slot=\"assistant-disabled-notice\"");
});
it("renders the inline variant without the Card wrapper", () => {
const html = render(
<AssistantDisabledNotice surface="correlations" variant="inline" />,
);
expect(html).toContain("<p");
expect(html).not.toContain("CardContent");
});
});
@@ -0,0 +1,91 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { InsightStatusCard } from "../insight-status-card";
/**
* v1.4.31 — the per-metric `<InsightStatusCard>` returns `null` when
* the operator has disabled the `insightStatus` surface. Renders
* normally otherwise.
*/
function wrap(node: React.ReactNode, client: QueryClient) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
const baseProps = {
title: "Pulse",
icon: null,
text: "Your pulse is stable.",
hasProvider: true,
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("<InsightStatusCard> — assistant.insightStatus gate", () => {
it("renders the card when the flag is on", () => {
const html = wrap(
<InsightStatusCard {...baseProps} />,
makeClient({
enabled: true,
coach: true,
briefing: true,
insightStatus: true,
correlations: true,
healthScoreExplainer: true,
}),
);
expect(html).toContain("Your pulse is stable");
});
it("returns null when the operator disables the sub-flag", () => {
const html = wrap(
<InsightStatusCard {...baseProps} />,
makeClient({
enabled: true,
coach: true,
briefing: true,
insightStatus: false,
correlations: true,
healthScoreExplainer: true,
}),
);
expect(html).toBe("");
});
it("returns null when the master flag is off", () => {
const html = wrap(
<InsightStatusCard {...baseProps} />,
makeClient({
enabled: false,
coach: false,
briefing: false,
insightStatus: false,
correlations: false,
healthScoreExplainer: false,
}),
);
expect(html).toBe("");
});
});
@@ -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.<surface>"` 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<AssistantDisabledSurface, string> = {
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 (
<p
data-slot="assistant-disabled-notice"
className={cn("text-muted-foreground text-sm", className)}
>
{message}
</p>
);
}
return (
<Card
data-slot="assistant-disabled-notice"
className={cn("opacity-70", className)}
>
<CardContent className="flex items-start gap-3 py-4">
<Sparkles
className="text-muted-foreground mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p className="text-muted-foreground text-sm">{message}</p>
</CardContent>
</Card>
);
}
@@ -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");
@@ -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");
@@ -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 (
@@ -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");
@@ -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 (
<CoachDrawer
open={launch.open}