diff --git a/messages/de.json b/messages/de.json
index ce28bb09c..288729d15 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2372,7 +2372,8 @@
"correlationMoodPulse": "Gibt es einen echten Zusammenhang zwischen meiner Stimmung und meinem Puls? Hilf mir, diesen Zusammenhang zu lesen.",
"correlationWeightWeekday": "Folgt mein Gewicht einem Wochenmuster? Was sagt mir dieser Wochentags-Zusammenhang?",
"recommendation": "Erzähl mir mehr zu dieser Empfehlung: \"{text}\" — warum trifft sie auf mich zu, und was sollte ich tun?"
- }
+ },
+ "assessmentPrompt": "Erkläre mir meine {metric}-Einschätzung — was bedeutet sie und was sollte ich tun?"
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
@@ -2444,7 +2445,8 @@
"emptyState": {
"title": "Noch kein Ruhepuls",
"description": "Der Ruhepuls erscheint, sobald die Apple Watch oder ein kompatibles Withings-Gerät einen ruhigen Tag durchgetragen hat."
- }
+ },
+ "coachPrefill": "Ich habe noch keinen Ruhepuls erfasst — warum unterscheidet sich der tägliche Ruhewert von einem punktuellen Puls, und wie verbessere ich ihn?"
},
"oxygenSaturation": {
"title": "Sauerstoffsättigung",
diff --git a/messages/en.json b/messages/en.json
index 14fa8b085..82ab39793 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2372,7 +2372,8 @@
"correlationMoodPulse": "Is there a real link between my mood and my pulse? Help me read this correlation.",
"correlationWeightWeekday": "Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?",
"recommendation": "Tell me more about this recommendation: \"{text}\" — why does it apply to me, and what should I do about it?"
- }
+ },
+ "assessmentPrompt": "Walk me through my {metric} assessment — what does it mean and what should I do about it?"
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
@@ -2444,7 +2445,8 @@
"emptyState": {
"title": "No resting heart rate yet",
"description": "Resting heart rate arrives once Apple Watch or a compatible Withings device has worn through one quiet day."
- }
+ },
+ "coachPrefill": "I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?"
},
"oxygenSaturation": {
"title": "Oxygen saturation",
diff --git a/messages/es.json b/messages/es.json
index ce2ee6de9..a62a65300 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2372,7 +2372,8 @@
"correlationMoodPulse": "¿Existe una relación real entre mi estado de ánimo y mi pulso? Ayúdame a interpretar esta correlación.",
"correlationWeightWeekday": "¿Mi peso sigue un patrón semanal? ¿Qué me dice esta correlación por día de la semana?",
"recommendation": "Cuéntame más sobre esta recomendación: \"{text}\" — ¿por qué se aplica a mí y qué debería hacer al respecto?"
- }
+ },
+ "assessmentPrompt": "Explícame mi evaluación de {metric}: ¿qué significa y qué debería hacer al respecto?"
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
@@ -2444,7 +2445,8 @@
"emptyState": {
"title": "Aún no hay FC en reposo",
"description": "La FC en reposo aparece cuando Apple Watch o un Withings compatible ha llevado puesto un día tranquilo."
- }
+ },
+ "coachPrefill": "Aún no he registrado ninguna frecuencia cardíaca en reposo: ¿por qué el valor diario en reposo difiere de un pulso puntual y cómo puedo mejorarlo?"
},
"oxygenSaturation": {
"title": "Saturación de oxígeno",
diff --git a/messages/fr.json b/messages/fr.json
index 69f085b74..32175a5e3 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2372,7 +2372,8 @@
"correlationMoodPulse": "Y a-t-il un lien réel entre mon humeur et mon pouls ? Aide-moi à interpréter cette corrélation.",
"correlationWeightWeekday": "Mon poids suit-il un schéma hebdomadaire ? Que m'indique cette corrélation selon le jour de la semaine ?",
"recommendation": "Parle-moi davantage de cette recommandation : \"{text}\" — pourquoi s'applique-t-elle à moi, et que devrais-je faire ?"
- }
+ },
+ "assessmentPrompt": "Explique-moi mon évaluation de {metric} : qu'est-ce que cela signifie et que dois-je faire ?"
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
@@ -2444,7 +2445,8 @@
"emptyState": {
"title": "Aucune FC au repos",
"description": "La FC au repos arrive dès que l'Apple Watch ou un appareil Withings compatible a porté une journée calme."
- }
+ },
+ "coachPrefill": "Je n'ai pas encore enregistré de fréquence cardiaque au repos — pourquoi la valeur quotidienne au repos diffère-t-elle d'un pouls ponctuel, et comment l'améliorer ?"
},
"oxygenSaturation": {
"title": "Saturation en oxygène",
diff --git a/messages/it.json b/messages/it.json
index 60e5288d4..d90e3a89a 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2372,7 +2372,8 @@
"correlationMoodPulse": "C'è un legame reale tra il mio umore e il mio battito? Aiutami a interpretare questa correlazione.",
"correlationWeightWeekday": "Il mio peso segue uno schema settimanale? Cosa mi dice questa correlazione per giorno della settimana?",
"recommendation": "Dimmi di più su questa raccomandazione: \"{text}\" — perché si applica a me e cosa dovrei fare?"
- }
+ },
+ "assessmentPrompt": "Spiegami la mia valutazione di {metric}: cosa significa e cosa dovrei fare?"
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
@@ -2444,7 +2445,8 @@
"emptyState": {
"title": "Ancora nessuna FC a riposo",
"description": "La FC a riposo arriva quando Apple Watch o un Withings compatibile ha indossato una giornata tranquilla."
- }
+ },
+ "coachPrefill": "Non ho ancora registrato alcuna frequenza cardiaca a riposo: perché il valore giornaliero a riposo è diverso da un battito occasionale e come posso migliorarlo?"
},
"oxygenSaturation": {
"title": "Saturazione di ossigeno",
diff --git a/messages/pl.json b/messages/pl.json
index 5631b6f90..2f3ec67fe 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2372,7 +2372,8 @@
"correlationMoodPulse": "Czy istnieje rzeczywisty związek między moim nastrojem a tętnem? Pomóż mi odczytać tę korelację.",
"correlationWeightWeekday": "Czy moja waga podlega cotygodniowemu wzorcowi? Co mówi mi ta korelacja według dnia tygodnia?",
"recommendation": "Powiedz mi więcej o tej rekomendacji: \"{text}\" — dlaczego mnie dotyczy i co powinienem z nią zrobić?"
- }
+ },
+ "assessmentPrompt": "Wyjaśnij mi moją ocenę {metric} — co oznacza i co powinienem z tym zrobić?"
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
@@ -2444,7 +2445,8 @@
"emptyState": {
"title": "Brak tętna spoczynkowego",
"description": "Tętno spoczynkowe pojawi się, gdy Apple Watch lub zgodne urządzenie Withings spędzi spokojny dzień na nadgarstku."
- }
+ },
+ "coachPrefill": "Nie zarejestrowałem jeszcze tętna spoczynkowego — dlaczego dzienna wartość spoczynkowa różni się od pojedynczego pomiaru tętna i jak ją poprawić?"
},
"oxygenSaturation": {
"title": "Saturacja tlenu",
diff --git a/src/app/insights/resting-pulse/page.tsx b/src/app/insights/resting-pulse/page.tsx
index ccf61598a..95a16af3a 100644
--- a/src/app/insights/resting-pulse/page.tsx
+++ b/src/app/insights/resting-pulse/page.tsx
@@ -3,6 +3,7 @@
import { Heart } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
+import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.32 — `/insights/resting-pulse`.
@@ -14,6 +15,7 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
* trend that's not muddied by mid-walk readings.
*/
export default function InsightsRestingHrPage() {
+ const { t } = useTranslations();
return (
}
emptyStateCtaType={null}
- coachPrefill="I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?"
+ coachPrefill={t("insights.restingHr.coachPrefill")}
/>
);
}
diff --git a/src/components/insights/ask-coach-action.tsx b/src/components/insights/ask-coach-action.tsx
index 1200ab309..ffc97490d 100644
--- a/src/components/insights/ask-coach-action.tsx
+++ b/src/components/insights/ask-coach-action.tsx
@@ -44,6 +44,12 @@ export interface AskCoachActionProps {
* briefing), which read better against the default all-source snapshot.
*/
scope?: CoachLaunchScope;
+ /**
+ * Auto-send the seeded question as the conversation's first turn instead
+ * of only seeding the composer. The assessment hand-off sets this so the
+ * Coach answers immediately; defaults to false (seed-only).
+ */
+ autoSend?: boolean;
/** Optional visible-label override; defaults to the shared CTA copy. */
label?: string;
/** className passthrough for per-card alignment. */
@@ -53,6 +59,7 @@ export interface AskCoachActionProps {
export function AskCoachAction({
question,
scope,
+ autoSend,
label,
className,
}: AskCoachActionProps) {
@@ -76,7 +83,7 @@ export function AskCoachAction({
variant="ghost"
size="sm"
data-slot="ask-coach-action"
- onClick={() => launch.askCoach(question, scope)}
+ onClick={() => launch.askCoach(question, scope, autoSend)}
className={cn(
// Discreet: muted text, hover lifts to foreground, no fill. Tight
// height so it sits as a quiet footer link, not a primary CTA.
diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx
index 5a9236f6d..c8aaa00df 100644
--- a/src/components/insights/coach-panel/coach-conversation.tsx
+++ b/src/components/insights/coach-panel/coach-conversation.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useReducer, useState } from "react";
+import { useEffect, useReducer, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -117,6 +117,13 @@ export interface CoachConversationProps {
* established scope. Null → the route's default all-source snapshot.
*/
launchScope?: CoachLaunchScope | null;
+ /**
+ * When true, the `prefill` is dispatched as the conversation's first turn
+ * automatically, exactly once on mount (ref-guarded). Used by the
+ * assessment hand-off so the answer lands without a manual send. The send
+ * only fires for a fresh conversation with a non-empty prefill.
+ */
+ autoSend?: boolean;
/**
* Renders the conversation title. The surface passes the resolved
* title string; the drawer wraps it in ``, the page in an
@@ -188,6 +195,7 @@ export interface CoachConversationProps {
export function CoachConversation({
prefill,
launchScope,
+ autoSend,
renderTitle,
renderDescription,
leadingHeaderActions,
@@ -340,6 +348,31 @@ export function CoachConversation({
}
}
+ // Auto-send the prefill as the conversation's first turn, exactly once.
+ // A card hand-off (e.g. the assessment "ask about this") opens the Coach
+ // with `autoSend` so the answer lands without a manual send. Ref-guarded so
+ // it fires a single time per mount; only for a fresh conversation with a
+ // non-empty prefill and no stream in flight.
+ const autoSentRef = useRef(false);
+ useEffect(() => {
+ if (!autoSend) return;
+ if (autoSentRef.current) return;
+ if (currentConversationId !== null) return;
+ if (send.isStreaming) return;
+ const seed = (prefill ?? "").trim();
+ if (!seed) return;
+ autoSentRef.current = true;
+ void handleSubmit(seed);
+ // `handleSubmit` is re-created each render but the ref guard ensures a
+ // single dispatch; the remaining deps gate the one allowed fire.
+ }, [
+ autoSend,
+ prefill,
+ currentConversationId,
+ send.isStreaming,
+ handleSubmit,
+ ]);
+
// v1.22 — "Try again": regenerate an assistant reply by resubmitting the
// user turn that produced it as a FRESH turn. The composer value is left
// untouched (unlike `handleSubmit`, which clears it) so a half-typed
diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx
index 490018b21..98e21ef4e 100644
--- a/src/components/insights/coach-panel/coach-drawer.tsx
+++ b/src/components/insights/coach-panel/coach-drawer.tsx
@@ -54,6 +54,11 @@ export interface CoachDrawerProps {
* source(s) + window. Null → the route's default all-source snapshot.
*/
scope?: CoachLaunchScope | null;
+ /**
+ * When true, the conversation auto-sends `prefill` as its first turn
+ * exactly once on open. Used by the assessment hand-off.
+ */
+ autoSend?: boolean;
}
export function CoachDrawer({
@@ -61,6 +66,7 @@ export function CoachDrawer({
onOpenChange,
prefill,
scope,
+ autoSend,
}: CoachDrawerProps) {
const { t } = useTranslations();
const router = useRouter();
@@ -139,6 +145,7 @@ export function CoachDrawer({
surface="drawer"
prefill={prefill}
launchScope={scope}
+ autoSend={autoSend}
autoFocusComposer
registerReset={registerReset}
// v1.16.1 — the "Conversations" affordance hands off to a
diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx
index f36c20ca9..03eeca56e 100644
--- a/src/components/insights/insight-status-card.tsx
+++ b/src/components/insights/insight-status-card.tsx
@@ -69,6 +69,11 @@ interface InsightStatusCardProps {
*/
coachQuestion?: string;
coachScope?: CoachLaunchScope;
+ /**
+ * Auto-send the seeded opener as the first turn (assessment hand-off)
+ * instead of only seeding the composer. Defaults to false.
+ */
+ coachAutoSend?: boolean;
}
// ─── Main Component ───────────────────────────────────────
@@ -83,6 +88,7 @@ export function InsightStatusCard({
preparing = false,
coachQuestion,
coachScope,
+ coachAutoSend,
}: InsightStatusCardProps) {
const { t } = useTranslations();
const flags = useFeatureFlags();
@@ -248,7 +254,11 @@ export function InsightStatusCard({
caller supplied an opener. */}
{coachQuestion ? (
) : null}
diff --git a/src/components/insights/layout-coach-mount.tsx b/src/components/insights/layout-coach-mount.tsx
index 1b0947430..b8e8be6be 100644
--- a/src/components/insights/layout-coach-mount.tsx
+++ b/src/components/insights/layout-coach-mount.tsx
@@ -56,6 +56,9 @@ export function LayoutCoachMount() {
// opened from a metric surface or insight card is pre-narrowed to
// the relevant source(s).
scope={launch.scope}
+ // Auto-send the prefill as the first turn when the launch requested it
+ // (assessment hand-off), so the answer lands without a manual send.
+ autoSend={launch.autoSend}
/>
);
}
diff --git a/src/components/insights/metric-status-card.tsx b/src/components/insights/metric-status-card.tsx
index 5644db56b..524401a96 100644
--- a/src/components/insights/metric-status-card.tsx
+++ b/src/components/insights/metric-status-card.tsx
@@ -46,12 +46,9 @@ export function MetricStatusCard({
// 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?`;
+ const coachQuestion = t("insights.coach.assessmentPrompt", {
+ metric: metric.toLowerCase().replace(/_/g, " "),
+ });
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/lib/insights/coach-launch-context.tsx b/src/lib/insights/coach-launch-context.tsx
index cf7754813..2313f3409 100644
--- a/src/lib/insights/coach-launch-context.tsx
+++ b/src/lib/insights/coach-launch-context.tsx
@@ -67,6 +67,12 @@ interface CoachLaunchValue {
open: boolean;
/** Current prefill string (or null when the next open should start blank). */
prefill: string | null;
+ /**
+ * When true, the opened conversation auto-sends the prefill as its first
+ * turn (exactly once), so a card's "ask about this" hand-off lands the
+ * answer directly instead of only seeding the composer. Cleared on close.
+ */
+ autoSend: boolean;
/**
* Scope the next/open conversation is narrowed to (or null for the
* default all-source snapshot). Set by `askCoach(prefill, scope)`, or
@@ -74,8 +80,16 @@ interface CoachLaunchValue {
* explicit one. `` forwards it to the drawer.
*/
scope: CoachLaunchScope | null;
- /** Open the drawer with an optional prefill + scope hint. */
- askCoach: (prefill?: string | null, scope?: CoachLaunchScope) => void;
+ /**
+ * Open the drawer with an optional prefill + scope hint. When `autoSend`
+ * is true the prefill is dispatched as the conversation's first turn
+ * automatically (used by the assessment hand-off).
+ */
+ askCoach: (
+ prefill?: string | null,
+ scope?: CoachLaunchScope,
+ autoSend?: boolean,
+ ) => void;
/**
* v1.21.0 (C4 H1) — register the metric surface the user is currently
* looking at so the global FAB (which calls `askCoach()` with no args)
@@ -110,6 +124,8 @@ export interface CoachLaunchProviderProps {
export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
const [open, setOpen] = useState(false);
const [prefill, setPrefill] = useState(null);
+ // Whether the next open auto-sends its prefill as the first turn.
+ const [autoSend, setAutoSend] = useState(false);
// Scope the open conversation is narrowed to. `null` → default snapshot.
const [scope, setScope] = useState(null);
// Ambient scope + seed opener of the metric surface currently on screen.
@@ -121,7 +137,11 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
const ambientPrefillRef = useRef(null);
const askCoach = useCallback(
- (nextPrefill?: string | null, nextScope?: CoachLaunchScope) => {
+ (
+ nextPrefill?: string | null,
+ nextScope?: CoachLaunchScope,
+ nextAutoSend?: boolean,
+ ) => {
// v1.21.0 (C4 H1/H4) — scope is live. An explicit scope (insight
// card, metric-card affordance) wins; otherwise inherit the metric
// page's ambient scope so the FAB opens contextual to where the user
@@ -135,6 +155,9 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
(usingAmbientScope ? ambientPrefillRef.current : null) ??
null,
);
+ // Auto-send only applies when an explicit prefill is given (a card
+ // hand-off), never for an ambient/blank open.
+ setAutoSend(Boolean(nextAutoSend) && Boolean(nextPrefill));
setOpen(true);
},
[],
@@ -160,9 +183,11 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
const handleSetOpen = useCallback((next: boolean) => {
setOpen(next);
if (!next) {
- // Drop the prefill + scope on close so the next open starts clean.
+ // Drop the prefill + scope + auto-send on close so the next open
+ // starts clean.
setPrefill(null);
setScope(null);
+ setAutoSend(false);
}
}, []);
@@ -170,12 +195,13 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
() => ({
open,
prefill,
+ autoSend,
scope,
askCoach,
registerScope,
setOpen: handleSetOpen,
}),
- [open, prefill, scope, askCoach, registerScope, handleSetOpen],
+ [open, prefill, autoSend, scope, askCoach, registerScope, handleSetOpen],
);
return (