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 (