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.