diff --git a/messages/de.json b/messages/de.json
index 2953dc13c..9d49cd290 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2286,7 +2286,8 @@
"thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat",
"thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.",
"tokensUsed": "{count} Tokens",
- "tokensUsedWithModel": "{count} Tokens · {model}"
+ "tokensUsedWithModel": "{count} Tokens · {model}",
+ "askAboutThis": "Den Coach dazu fragen"
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
diff --git a/messages/en.json b/messages/en.json
index dddad7809..ba04271ee 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2286,7 +2286,8 @@
"thinkingExpandAria": "Show what the Coach considered",
"thinkingDetail": "Answered from your health snapshot.",
"tokensUsed": "{count} tokens",
- "tokensUsedWithModel": "{count} tokens · {model}"
+ "tokensUsedWithModel": "{count} tokens · {model}",
+ "askAboutThis": "Ask the coach about this"
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
diff --git a/messages/es.json b/messages/es.json
index 9c7719abb..4596b221c 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2286,7 +2286,8 @@
"thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta",
"thinkingDetail": "Respondido a partir de tu resumen de salud.",
"tokensUsed": "{count} tokens",
- "tokensUsedWithModel": "{count} tokens · {model}"
+ "tokensUsedWithModel": "{count} tokens · {model}",
+ "askAboutThis": "Pregúntale al coach sobre esto"
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
diff --git a/messages/fr.json b/messages/fr.json
index dfabd04d3..82b1156d4 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2286,7 +2286,8 @@
"thinkingExpandAria": "Montrer ce que le Coach a pris en compte",
"thinkingDetail": "Réponse établie à partir de ton aperçu santé.",
"tokensUsed": "{count} jetons",
- "tokensUsedWithModel": "{count} jetons · {model}"
+ "tokensUsedWithModel": "{count} jetons · {model}",
+ "askAboutThis": "Interroger le coach à ce sujet"
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
diff --git a/messages/it.json b/messages/it.json
index 53fd5ec0d..4e611a6ee 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2286,7 +2286,8 @@
"thinkingExpandAria": "Mostra cosa ha considerato il Coach",
"thinkingDetail": "Risposta basata sul tuo riepilogo di salute.",
"tokensUsed": "{count} token",
- "tokensUsedWithModel": "{count} token · {model}"
+ "tokensUsedWithModel": "{count} token · {model}",
+ "askAboutThis": "Chiedi al coach"
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
diff --git a/messages/pl.json b/messages/pl.json
index 5afe3bc9b..fda773d42 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2286,7 +2286,8 @@
"thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę",
"thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.",
"tokensUsed": "{count} tokenów",
- "tokensUsedWithModel": "{count} tokenów · {model}"
+ "tokensUsedWithModel": "{count} tokenów · {model}",
+ "askAboutThis": "Zapytaj trenera o to"
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
diff --git a/src/components/insights/__tests__/recommendation-card-confidence.test.tsx b/src/components/insights/__tests__/recommendation-card-confidence.test.tsx
index c45c48f1f..12f3edbaa 100644
--- a/src/components/insights/__tests__/recommendation-card-confidence.test.tsx
+++ b/src/components/insights/__tests__/recommendation-card-confidence.test.tsx
@@ -1,4 +1,5 @@
import { describe, it, expect, vi } from "vitest";
+import { createContext } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { RecommendationCard } from "../recommendation-card";
@@ -24,6 +25,10 @@ vi.mock("@tanstack/react-query", () => ({
error: null,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+ // v1.21.0 — the card footer's `` reaches the
+ // query-client mount probe; a null-default context reads as "no client
+ // mounted" so the Coach hooks return their fail-open defaults.
+ QueryClientContext: createContext(null),
}));
vi.mock("@/hooks/use-auth", () => ({
diff --git a/src/components/insights/__tests__/recommendation-card.test.tsx b/src/components/insights/__tests__/recommendation-card.test.tsx
index 952f1068f..1fddb563d 100644
--- a/src/components/insights/__tests__/recommendation-card.test.tsx
+++ b/src/components/insights/__tests__/recommendation-card.test.tsx
@@ -1,4 +1,5 @@
import { describe, it, expect, vi } from "vitest";
+import { createContext } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { RecommendationCard } from "../recommendation-card";
@@ -66,6 +67,11 @@ vi.mock("@tanstack/react-query", () => ({
error: null,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+ // v1.21.0 — the card's footer `` reaches the
+ // query-client mount probe (`useFeatureFlags` / `useDisableCoach`); the
+ // probe only needs the context to exist. A null-default context reads
+ // as "no client mounted" so both hooks return their fail-open defaults.
+ QueryClientContext: createContext(null),
}));
vi.mock("@/hooks/use-auth", () => ({
diff --git a/src/components/insights/__tests__/recommendations-grid.test.tsx b/src/components/insights/__tests__/recommendations-grid.test.tsx
index ce7400963..15dc74dcc 100644
--- a/src/components/insights/__tests__/recommendations-grid.test.tsx
+++ b/src/components/insights/__tests__/recommendations-grid.test.tsx
@@ -1,4 +1,5 @@
import { describe, it, expect, vi } from "vitest";
+import { createContext } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
@@ -35,6 +36,10 @@ vi.mock("@tanstack/react-query", () => ({
error: null,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+ // v1.21.0 — the card footer's `` reaches the
+ // query-client mount probe; a null-default context reads as "no client
+ // mounted" so the Coach hooks return their fail-open defaults.
+ QueryClientContext: createContext(null),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
diff --git a/src/components/insights/ask-coach-action.tsx b/src/components/insights/ask-coach-action.tsx
new file mode 100644
index 000000000..1200ab309
--- /dev/null
+++ b/src/components/insights/ask-coach-action.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { Sparkles } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { useTranslations } from "@/lib/i18n/context";
+import {
+ useCoachLaunch,
+ type CoachLaunchScope,
+} from "@/lib/insights/coach-launch-context";
+import { useFeatureFlags } from "@/hooks/use-feature-flags";
+import { useDisableCoach } from "@/hooks/use-disable-coach";
+
+/**
+ * v1.21.0 (C4 H2) — discreet "Ask the Coach about this" affordance for
+ * high-value insight / assessment cards (briefing, recommendation,
+ * correlation, status, health-score, period narrative).
+ *
+ * It is the reverse-direction entry point the C4 audit flagged as
+ * missing: the cards carry the richest context in the app, so a single
+ * small action per card opens the Coach pre-scoped to that card's topic
+ * with a seeded opener. Reuses the existing launch context — no parallel
+ * launch system.
+ *
+ * Subtle by construction: a ghost, muted-foreground text+icon button (no
+ * alarming colour, no card-tinting — the medication-card rule holds). One
+ * entry point per card, never a cluster.
+ *
+ * Self-gates on the same triple the FAB / inline pill use (provider
+ * mounted + operator flag + per-user opt-out) so a card never paints a
+ * dead Coach control.
+ */
+export interface AskCoachActionProps {
+ /**
+ * Composer seed question — a plain-English opener about the card's
+ * topic. The Coach treats it as composer seed text, not an i18n key
+ * (the chat route is EN/DE-gated), matching the empty-state convention.
+ */
+ question: string;
+ /**
+ * Optional scope so the conversation narrows to the card's source(s).
+ * Omitted for cards that span the whole picture (e.g. the daily
+ * briefing), which read better against the default all-source snapshot.
+ */
+ scope?: CoachLaunchScope;
+ /** Optional visible-label override; defaults to the shared CTA copy. */
+ label?: string;
+ /** className passthrough for per-card alignment. */
+ className?: string;
+}
+
+export function AskCoachAction({
+ question,
+ scope,
+ label,
+ className,
+}: AskCoachActionProps) {
+ const { t } = useTranslations();
+ const launch = useCoachLaunch();
+ const flags = useFeatureFlags();
+ const disableCoach = useDisableCoach();
+
+ // Same gate posture as / : render
+ // nothing unless the provider is mounted, the operator flag is on, and
+ // the user has not opted out.
+ if (!launch) return null;
+ if (!flags.coach) return null;
+ if (disableCoach) return null;
+
+ const accessibleLabel = label ?? t("insights.coach.askAboutThis");
+
+ return (
+
+ );
+}
diff --git a/src/components/insights/correlation-card.tsx b/src/components/insights/correlation-card.tsx
index 697e9615e..b447f2d42 100644
--- a/src/components/insights/correlation-card.tsx
+++ b/src/components/insights/correlation-card.tsx
@@ -10,7 +10,9 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { CorrelationResult } from "@/lib/insights/correlations";
+import type { CoachScopeSource } from "@/lib/ai/coach/types";
import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
+import { AskCoachAction } from "./ask-coach-action";
/**
* v1.4.20 phase B3 — single Correlation card.
@@ -71,10 +73,36 @@ const CONFIDENCE_LABEL_KEY: Record<"low" | "moderate" | "high", string> = {
low: "insights.correlationRow.confidenceLow",
};
+// v1.21.0 (C4 H2) — the two sources each correlation pair spans, so the
+// Coach hand-off narrows its snapshot to exactly the relationship the
+// card describes.
+const COACH_SOURCES_BY_KIND: Record<
+ CorrelationResult["kind"],
+ CoachScopeSource[]
+> = {
+ "bp-compliance": ["bp", "compliance"],
+ "mood-pulse": ["mood", "pulse"],
+ "weight-weekday": ["weight"],
+};
+
+const COACH_QUESTION_BY_KIND: Record = {
+ "bp-compliance":
+ "How does my medication adherence relate to my blood pressure? Walk me through what this correlation means for me.",
+ "mood-pulse":
+ "Is there a real link between my mood and my pulse? Help me read this correlation.",
+ "weight-weekday":
+ "Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?",
+};
+
export function CorrelationCard({ result }: CorrelationCardProps) {
const { t } = useTranslations();
const title = t(TITLE_KEY[result.kind]);
const subtitle = t(SUBTITLE_KEY[result.kind]);
+ const coachSources = COACH_SOURCES_BY_KIND[result.kind];
+ const coachScope =
+ coachSources.length > 0
+ ? { metric: coachSources[0], also: coachSources.slice(1) }
+ : undefined;
return (
-
+
+
+ {/* v1.21.0 (C4 H2) — the live Coach hand-off sits beside the
+ roadmap CTA: it scopes to both sources in the pair. */}
+
+
>
) : (
)}
+ {/* v1.21.0 (C4 H2) — hand off to the Coach for the whole
+ picture. The briefing spans every metric, so no scope: the
+ default all-source snapshot reads best here. */}
+
+
+
) : noProvider ? (
// v1.15.20 — no provider configured anywhere: a regenerate CTA
diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx
index 558955bf8..1c92e616e 100644
--- a/src/components/insights/health-score-card.tsx
+++ b/src/components/insights/health-score-card.tsx
@@ -5,6 +5,7 @@ import { ArrowDown, ArrowUp, ChevronDown, Minus, Moon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
+import { AskCoachAction } from "./ask-coach-action";
/**
* v1.4.20 B5 — Personal Health Score panel.
@@ -616,6 +617,16 @@ export function HealthScoreCard({
{/* v1.18.6 (DISC-01) — the "indicative, not a clinical assessment"
card disclaimer is removed; the one-time onboarding acknowledgment
now covers the not-a-diagnosis framing app-wide. */}
+
+ {/* v1.21.0 (C4 H2) — the score is a whole-picture composite, so the
+ hand-off opens the Coach against the default snapshot (no scope)
+ seeded with the actual number. Mounts at the foot of the card as
+ a single discreet entry, not a primary CTA. */}
+
+
+
);
diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx
index a4682ed07..9281606ba 100644
--- a/src/components/insights/insight-status-card.tsx
+++ b/src/components/insights/insight-status-card.tsx
@@ -11,6 +11,8 @@ import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { cn } from "@/lib/utils";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
+import { AskCoachAction } from "@/components/insights/ask-coach-action";
+import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
// ─── Types ────────────────────────────────────────────────
@@ -56,6 +58,15 @@ interface InsightStatusCardProps {
* card is being assembled, not stuck. The client polls until text lands.
*/
preparing?: boolean;
+ /**
+ * v1.21.0 (C4 H2) — opt-in "Ask the Coach about this assessment" hand-off.
+ * When `coachQuestion` is set (the metric-aware caller supplies it), the
+ * populated card renders a discreet Coach action seeded with that opener
+ * and narrowed to `coachScope` when known. Callers that omit it keep the
+ * card exactly as before — additive.
+ */
+ coachQuestion?: string;
+ coachScope?: CoachLaunchScope;
}
// ─── Main Component ───────────────────────────────────────
@@ -68,6 +79,8 @@ export function InsightStatusCard({
updatedAt,
loading = false,
preparing = false,
+ coachQuestion,
+ coachScope,
}: InsightStatusCardProps) {
const { t } = useTranslations();
const flags = useFeatureFlags();
@@ -228,6 +241,14 @@ export function InsightStatusCard({
they roll forward. */}
+ {/* v1.21.0 (C4 H2) — discreet hand-off so the user can take the
+ assessment into a scoped Coach conversation. Only when the
+ caller supplied an opener. */}
+ {coachQuestion ? (
+
+
+
+ ) : null}
);
diff --git a/src/components/insights/metric-status-card.tsx b/src/components/insights/metric-status-card.tsx
index d91311599..5644db56b 100644
--- a/src/components/insights/metric-status-card.tsx
+++ b/src/components/insights/metric-status-card.tsx
@@ -7,6 +7,7 @@ import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
+import { scopeSourceFromMetricKey } from "@/components/insights/coach-metric-scope";
interface MetricStatusCardProps {
/** The registry metric id the generic assessment route keys on. */
@@ -39,6 +40,19 @@ export function MetricStatusCard({
const mounted = useMounted();
const { data: status, isLoading } = useInsightMetricStatus(metric, enabled);
+ // v1.21.0 (C4 H2) — narrow the Coach hand-off to this metric's snapshot
+ // source when one exists; the registry id is a `MeasurementType`-style
+ // token, which `scopeSourceFromMetricKey` resolves for the metrics that
+ // carry a snapshot block. Unmapped metrics still get a metric-named
+ // opener, just against the default snapshot.
+ const coachSource = scopeSourceFromMetricKey(metric);
+ const coachQuestion = `Walk me through my ${metric
+ .toLowerCase()
+ .replace(
+ /_/g,
+ " ",
+ )} assessment — what does it mean and what should I do about it?`;
+
return (
`: the hook is
// `enabled: isAuthenticated && enabled`, and both inputs can settle
// before a late-hydrating boundary replays its first render — the
diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx
index 89647228f..dd2ccce1d 100644
--- a/src/components/insights/period-narrative-card.tsx
+++ b/src/components/insights/period-narrative-card.tsx
@@ -8,6 +8,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
+import { AskCoachAction } from "@/components/insights/ask-coach-action";
import {
Popover,
PopoverContent,
@@ -225,6 +226,21 @@ export function PeriodNarrativeCard({
})}
) : null}
+
+ {/* v1.21.0 (C4 H2) — hand the period summary to the Coach. It spans
+ the whole picture for the window, so no scope: the default
+ snapshot reads best. */}
+ {narrative ? (
+
+
+
+ ) : null}
);
diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx
index 794d44ad3..3ec2a930f 100644
--- a/src/components/insights/recommendation-card.tsx
+++ b/src/components/insights/recommendation-card.tsx
@@ -16,6 +16,8 @@ import { importWithRetry } from "@/lib/retry-import";
import { getMedicalReferenceById } from "@/lib/ai/medical-references";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import type { Locale } from "@/lib/i18n/config";
+import { AskCoachAction } from "./ask-coach-action";
+import { scopeSourceFromMetricKey } from "./coach-metric-scope";
import {
RecommendationFeedback,
type RecommendationFeedbackSeverity,
@@ -349,6 +351,14 @@ export function RecommendationCard({
}
: null;
+ // v1.21.0 (C4 H2) — reverse-direction Coach hand-off. The opener
+ // quotes the rec so the conversation lands on this exact suggestion;
+ // when the rec carries a metric source, scope the snapshot to it.
+ const coachScopeSource = scopeSourceFromMetricKey(norm.metricSource?.type);
+ const coachQuestion = `Tell me more about this recommendation: "${stripChartTokens(
+ norm.text,
+ )}" — why does it apply to me, and what should I do about it?`;
+
return (
)}
+
+ {/* v1.21.0 (C4 H2) — discreet "ask the Coach about this rec" link,
+ aligned under the rec text (the number gutter is `ml-6`). */}
+
+
+
);
}
diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx
index 9b400dcfd..b45b9faac 100644
--- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx
+++ b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx
@@ -271,6 +271,10 @@ describe("Coach disable cascade invariant", () => {
const KNOWN_COACH_GATE_SITES: ReadonlyArray = [
// 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",
diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx
index 2675bc92e..63942e5ec 100644
--- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx
+++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx
@@ -282,6 +282,11 @@ describe("Coach per-user disableCoach invariant", () => {
// 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.