diff --git a/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx b/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx
new file mode 100644
index 000000000..1d6bfb37c
--- /dev/null
+++ b/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx
@@ -0,0 +1,76 @@
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+/**
+ * The layout shell wraps every Insights page. It owns the regenerate button
+ * on the tab strip but shows no briefing, so it must not read the briefing
+ * on entry: on a metric page that read was a request (and the schema chunk
+ * that parses its answer) for a button. The button itself stays.
+ */
+
+const queries: Array<{ queryKey: readonly unknown[]; enabled?: boolean }> = [];
+const stripProps: Array<{ onRegenerate?: unknown }> = [];
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: (options: { queryKey: readonly unknown[]; enabled?: boolean }) => {
+ queries.push(options);
+ return { data: undefined, isLoading: false, isError: false };
+ },
+ useMutation: () => ({ mutate: vi.fn(), isPending: false, data: undefined }),
+ useQueryClient: () => ({
+ getQueryCache: () => ({ find: () => undefined }),
+ }),
+}));
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({ isAuthenticated: true, user: { modules: {} } }),
+}));
+vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
+vi.mock("@/hooks/use-ai-capability", () => ({
+ useAiCapability: () => ({ available: true }),
+ useAiProviderState: () => ({ responseTimeoutMs: null }),
+}));
+vi.mock("@/hooks/use-surface-visible", () => ({
+ useSurfaceVisible: () => true,
+}));
+vi.mock("@/hooks/use-workouts", () => ({
+ useWorkouts: () => ({ data: undefined }),
+}));
+vi.mock("@/lib/queries/use-analytics-query", () => ({
+ useAnalyticsQuery: () => ({ data: undefined }),
+}));
+vi.mock("@/hooks/use-insights-layout", () => ({
+ useInsightsLayoutQuery: () => ({
+ layout: { tiles: [] },
+ isSuccess: false,
+ }),
+}));
+vi.mock("@/components/insights/use-ecg-list", () => ({
+ useEcgList: () => ({ data: undefined }),
+}));
+vi.mock("@/components/insights/insights-tab-strip", () => ({
+ InsightsTabStrip: (props: { onRegenerate?: unknown }) => {
+ stripProps.push(props);
+ return null;
+ },
+}));
+
+import { queryKeys } from "@/lib/query-keys";
+import { InsightsLayoutShell } from "../insights-layout-shell";
+
+describe("InsightsLayoutShell — what it reads on entry", () => {
+ it("keeps the regenerate button and sends no briefing read", () => {
+ renderToStaticMarkup(
+
+
+ ,
+ );
+
+ const advisorKey = JSON.stringify(queryKeys.insightsAdvisor());
+ const advisor = queries.filter(
+ (q) => JSON.stringify(q.queryKey) === advisorKey,
+ );
+ expect(advisor).toHaveLength(1);
+ expect(advisor[0].enabled).toBe(false);
+ expect(typeof stripProps[0]?.onRegenerate).toBe("function");
+ });
+});
diff --git a/src/components/insights/__tests__/use-insights-advisor-baseline.test.ts b/src/components/insights/__tests__/use-insights-advisor-baseline.test.ts
new file mode 100644
index 000000000..e1cb0c54f
--- /dev/null
+++ b/src/components/insights/__tests__/use-insights-advisor-baseline.test.ts
@@ -0,0 +1,68 @@
+/**
+ * The tab strip owns the regenerate button on every Insights page but shows
+ * no briefing, so it no longer reads one on entry. A regenerate from a page
+ * that never read the briefing still needs the briefing it replaces as the
+ * baseline a timed-out attempt settles against; without it the old briefing
+ * would read as the fresh one. `ensureAdvisorBaseline` reads it first, and
+ * only when the cache holds none.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { QueryClient } from "@tanstack/react-query";
+
+const apiFetchRaw = vi.fn();
+
+vi.mock("@/lib/api/api-fetch", () => ({
+ apiFetchRaw: (...a: unknown[]) => apiFetchRaw(...a),
+}));
+
+import { queryKeys } from "@/lib/query-keys";
+import { ensureAdvisorBaseline } from "../use-insights-advisor";
+
+const CACHED_AT = "2026-11-10T05:00:00.000Z";
+
+beforeEach(() => {
+ apiFetchRaw.mockReset().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ data: { insights: {}, cached: true, cachedAt: CACHED_AT },
+ }),
+ } as unknown as Response);
+});
+
+describe("ensureAdvisorBaseline", () => {
+ it("reads the briefing a regenerate replaces when nothing has read it yet", async () => {
+ const queryClient = new QueryClient();
+
+ await ensureAdvisorBaseline(queryClient);
+
+ expect(apiFetchRaw).toHaveBeenCalledTimes(1);
+ expect(apiFetchRaw.mock.calls[0][1]).toMatchObject({ method: "GET" });
+ const cached = queryClient.getQueryData<{
+ payload: { cachedAt: string } | null;
+ }>(queryKeys.insightsAdvisor());
+ expect(cached?.payload?.cachedAt).toBe(CACHED_AT);
+ });
+
+ it("asks nothing when the overview already read it", async () => {
+ const queryClient = new QueryClient();
+ queryClient.setQueryData(queryKeys.insightsAdvisor(), {
+ payload: { cachedAt: CACHED_AT },
+ outcome: "fresh",
+ });
+
+ await ensureAdvisorBaseline(queryClient);
+
+ expect(apiFetchRaw).not.toHaveBeenCalled();
+ });
+
+ it("leaves the baseline empty when the read fails", async () => {
+ apiFetchRaw.mockRejectedValue(new Error("offline"));
+ const queryClient = new QueryClient();
+
+ await expect(ensureAdvisorBaseline(queryClient)).resolves.toBeUndefined();
+ expect(
+ queryClient.getQueryData(queryKeys.insightsAdvisor()),
+ ).toBeUndefined();
+ });
+});
diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx
index 0fb78dffd..7bba6a385 100644
--- a/src/components/insights/insights-layout-shell.tsx
+++ b/src/components/insights/insights-layout-shell.tsx
@@ -24,12 +24,12 @@ import { useEcgList } from "@/components/insights/use-ecg-list";
/**
* v1.4.25 — client shell for `src/app/insights/layout.tsx`.
*
- * Owns the advisor query so the regenerate button on the sticky tab
- * strip works from every Insights surface (mother + the seven sub-
- * pages). TanStack Query dedup keeps the cost negligible: the mother
- * page's own `useInsightsAdvisorQuery` consumer shares the same
- * `queryKeys.insightsAdvisor()` key, so the two consumers reuse the
- * same cache entry without extra network traffic.
+ * Owns the regenerate button on the sticky tab strip, so it works from
+ * every Insights surface. The strip shows no briefing, so the shell does
+ * not read it: the overview's own `useInsightsAdvisorQuery` does, and the
+ * shell's hook reads it only while its regenerate settles. Every metric
+ * page used to send the briefing read (and load the schema chunk that
+ * parses it) for a button.
*
* v1.4.27 — also owns the analytics + comprehensive reads that
* power the tab-strip availability gate. The analytics consumer goes
@@ -55,8 +55,8 @@ interface ComprehensivePayload {
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const { isAuthenticated, user } = useAuth();
- // The briefing read and the regenerate button follow the `briefing`
- // capability on `/api/auth/me`: the server resolves the operator's switch,
+ // The regenerate button follows the `briefing` capability on
+ // `/api/auth/me`: the server resolves the operator's switch,
// the person's AI analysis opt-out, provider presence and consent into one
// answer, and the strip offers regenerate only while that answer is yes.
// Unavailable while `/me` loads, so nothing fires before the answer lands.
@@ -68,7 +68,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const mounted = useMounted();
const advisorEnabled = mounted && isAuthenticated && briefing.available;
- const advisor = useInsightsAdvisorQuery(advisorEnabled);
+ const advisor = useInsightsAdvisorQuery(false);
// Shared analytics fetch — the layout shell only reads
// `summaries[METRIC].count` for the tab-strip availability gate, so
diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx
index 53981829d..23b012e52 100644
--- a/src/components/insights/insights-tab-strip.tsx
+++ b/src/components/insights/insights-tab-strip.tsx
@@ -61,20 +61,17 @@ import { isSurfaceVisible } from "@/lib/modules/surface";
* - Active pill gets `aria-current="page"`.
* - The regenerate button has an `aria-label` so the icon-only
* affordance is announced. The button only renders when
- * `onRegenerate` is wired — sub-pages don't carry the regenerate
- * handler today (the mother page owns the advisor query and
- * forwards the trigger via the layout slot).
+ * `onRegenerate` is wired; the layout shell wires it on every
+ * Insights page while the briefing capability is available.
* - `prefers-reduced-motion` honoured at the global level (no
* scroll-into-view here — Next.js routing handles focus + scroll).
*/
export interface InsightsTabStripProps {
/**
- * Optional regenerate handler. Only the mother page passes this;
- * sub-pages render the strip without the right-slot button.
- * Wiring the strip in `layout.tsx` means we'd need a layout-level
- * advisor query — for v1.4.25 we keep the strip stateless and the
- * regenerate affordance on the mother page only.
+ * Optional regenerate handler. The layout shell passes it on every
+ * Insights page while the briefing capability is available; without
+ * it the strip renders no customise or regenerate control.
*/
onRegenerate?: () => void;
/** Spinner state — disables the button and swaps the icon. */
diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts
index 929ed2717..27c61e142 100644
--- a/src/components/insights/use-insights-advisor.ts
+++ b/src/components/insights/use-insights-advisor.ts
@@ -1,7 +1,12 @@
"use client";
import { useCallback, useState } from "react";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ useMutation,
+ useQuery,
+ useQueryClient,
+ type QueryClient,
+} from "@tanstack/react-query";
import { pollAttemptCount } from "@/lib/queries/refetch-interval";
import type { InsightResult } from "@/lib/ai/types";
// Type-only — the runtime schemas load lazily inside `fetchAdvisor` so
@@ -429,6 +434,27 @@ export async function fetchAdvisor(
return { payload, outcome: "fresh" };
}
+/**
+ * The payload a regenerate settles against. A surface that shows no briefing
+ * (the tab strip on a metric page) has not read it, and a timed-out
+ * regenerate without a baseline takes whatever briefing the cache holds for
+ * the fresh one. So the read runs first when the cache is empty; a failed
+ * read leaves the baseline empty, as it was before.
+ */
+export async function ensureAdvisorBaseline(
+ queryClient: QueryClient,
+): Promise {
+ if (queryClient.getQueryData(queryKeys.insightsAdvisor()) !== undefined) {
+ return;
+ }
+ await queryClient
+ .fetchQuery({
+ queryKey: queryKeys.insightsAdvisor(),
+ queryFn: () => fetchAdvisor(),
+ })
+ .catch(() => undefined);
+}
+
export interface UseInsightsAdvisorResult {
payload: InsightAdvisorPayload | null;
isLoading: boolean;
@@ -485,7 +511,9 @@ export interface UseInsightsAdvisorResult {
* available, whatever the caller passes: every consumer (the overview, the
* tab strip) gets the same gate, and none can re-enable the read behind the
* other's back. While `/me` loads the capability reads unavailable, so the
- * first paint never fires a request only to have it refused.
+ * first paint never fires a request only to have it refused. The tab strip
+ * passes `false`: it shows no briefing and only owns the regenerate button,
+ * so it reads the briefing only while its own regenerate settles.
*/
export function useInsightsAdvisorQuery(
enabled: boolean,
@@ -506,7 +534,9 @@ export function useInsightsAdvisorQuery(
// outcome) so the read path can surface WHY a payload is missing
// (`no-provider` → connect-AI hint instead of a dead regenerate CTA).
queryFn: () => fetchAdvisor(),
- enabled: enabled && briefing.available,
+ // A caller that shows no briefing still reads it while its own
+ // regenerate settles: the settle is decided on these polls.
+ enabled: (enabled || settling !== null) && briefing.available,
// 24h cache window matches the server-side `insightsCachedAt` TTL.
staleTime: 60 * 60 * 1000,
retry: false,
@@ -556,7 +586,10 @@ export function useInsightsAdvisorQuery(
}
const mutation = useMutation({
- mutationFn: () => fetchAdvisor({ force: true, responseTimeoutMs }),
+ mutationFn: async () => {
+ await ensureAdvisorBaseline(queryClient);
+ return fetchAdvisor({ force: true, responseTimeoutMs });
+ },
onSuccess: (result) => {
if (result.payload) {
// A genuinely fresh generation landed — write it into the shared