mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
aeff47afde
commit
03d0b64bb8
11 files changed
+433
-8
No files matched your search
@@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import { Loader2, TrendingUp } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
@@ -115,6 +116,7 @@ export default function InsightsPage() {
|
||||
// on the same context, and the drawer itself is mounted next to
|
||||
// the provider in `src/app/insights/layout.tsx`.
|
||||
const coachLaunch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["insights", "comprehensive"],
|
||||
@@ -194,15 +196,17 @@ export default function InsightsPage() {
|
||||
healthScore={analytics?.healthScore ?? undefined}
|
||||
/>
|
||||
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
loading={advisor.isLoading}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
/>
|
||||
{flags.briefing && (
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
loading={advisor.isLoading}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
{analytics?.correlations && (
|
||||
{flags.correlations && analytics?.correlations && (
|
||||
<CorrelationRow results={analytics.correlations} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
DEFAULT_ASSISTANT_FLAGS,
|
||||
useFeatureFlags,
|
||||
} from "../use-feature-flags";
|
||||
|
||||
function Probe() {
|
||||
const flags = useFeatureFlags();
|
||||
return <pre data-testid="flags">{JSON.stringify(flags)}</pre>;
|
||||
}
|
||||
|
||||
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(<Probe />);
|
||||
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(
|
||||
<QueryClientProvider client={client}>
|
||||
<Probe />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={client}>
|
||||
<Probe />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(html).toContain(""coach":false");
|
||||
expect(html).toContain(""insightStatus":false");
|
||||
expect(html).toContain(""healthScoreExplainer":false");
|
||||
});
|
||||
});
|
||||
@@ -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/<metric>` 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<FeatureFlagsPayload> {
|
||||
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 (`<InsightStatusCard>`, `<HealthScoreDeltaExplainer>`)
|
||||
* whose unit tests render the component without a
|
||||
* `<QueryClientProvider>`. 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<any>);
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user