diff --git a/messages/de.json b/messages/de.json index 535b44c94..6c931e2e5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2268,7 +2268,15 @@ "dictate": "Diktieren", "dictateStop": "Diktat beenden", "showConversations": "Unterhaltungen anzeigen", - "hideConversations": "Unterhaltungen ausblenden" + "hideConversations": "Unterhaltungen ausblenden", + "heroGreeting": "Wie kann ich dir helfen?", + "heroSubline": "Frag nach deinen Verläufen, Medikamenten oder Messwerten.", + "thinkingElapsed": "{seconds}s", + "thinkingDuration": "Nachgedacht für {seconds}s", + "thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat", + "thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.", + "tokensUsed": "{count} Tokens", + "tokensUsedWithModel": "{count} Tokens · {model}" }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", diff --git a/messages/en.json b/messages/en.json index b2372522b..838014b3b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2268,7 +2268,15 @@ "dictate": "Dictate", "dictateStop": "Stop dictation", "showConversations": "Show conversations", - "hideConversations": "Hide conversations" + "hideConversations": "Hide conversations", + "heroGreeting": "How can I help you?", + "heroSubline": "Ask about your trends, medications, or readings.", + "thinkingElapsed": "{seconds}s", + "thinkingDuration": "Thought for {seconds}s", + "thinkingExpandAria": "Show what the Coach considered", + "thinkingDetail": "Answered from your health snapshot.", + "tokensUsed": "{count} tokens", + "tokensUsedWithModel": "{count} tokens · {model}" }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", diff --git a/messages/es.json b/messages/es.json index be2580372..f3837a403 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2268,7 +2268,15 @@ "dictate": "Dictar", "dictateStop": "Detener el dictado", "showConversations": "Mostrar conversaciones", - "hideConversations": "Ocultar conversaciones" + "hideConversations": "Ocultar conversaciones", + "heroGreeting": "¿En qué puedo ayudarte?", + "heroSubline": "Pregunta por tus tendencias, medicamentos o mediciones.", + "thinkingElapsed": "{seconds}s", + "thinkingDuration": "Pensó durante {seconds}s", + "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}" }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", diff --git a/messages/fr.json b/messages/fr.json index 4cbe3b34f..dc8e2b96d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2268,7 +2268,15 @@ "dictate": "Dicter", "dictateStop": "Arrêter la dictée", "showConversations": "Afficher les conversations", - "hideConversations": "Masquer les conversations" + "hideConversations": "Masquer les conversations", + "heroGreeting": "Comment puis-je t'aider ?", + "heroSubline": "Pose une question sur tes tendances, médicaments ou mesures.", + "thinkingElapsed": "{seconds}s", + "thinkingDuration": "A réfléchi pendant {seconds}s", + "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}" }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", diff --git a/messages/it.json b/messages/it.json index 5866e29ba..c76899edb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2268,7 +2268,15 @@ "dictate": "Detta", "dictateStop": "Interrompi dettatura", "showConversations": "Mostra conversazioni", - "hideConversations": "Nascondi conversazioni" + "hideConversations": "Nascondi conversazioni", + "heroGreeting": "Come posso aiutarti?", + "heroSubline": "Chiedi dei tuoi andamenti, farmaci o misurazioni.", + "thinkingElapsed": "{seconds}s", + "thinkingDuration": "Ha riflettuto per {seconds}s", + "thinkingExpandAria": "Mostra cosa ha considerato il Coach", + "thinkingDetail": "Risposta basata sul tuo riepilogo di salute.", + "tokensUsed": "{count} token", + "tokensUsedWithModel": "{count} token · {model}" }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", diff --git a/messages/pl.json b/messages/pl.json index 1acef823f..5c14f1f0e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2268,7 +2268,15 @@ "dictate": "Dyktuj", "dictateStop": "Zatrzymaj dyktowanie", "showConversations": "Pokaż rozmowy", - "hideConversations": "Ukryj rozmowy" + "hideConversations": "Ukryj rozmowy", + "heroGreeting": "W czym mogę pomóc?", + "heroSubline": "Zapytaj o swoje trendy, leki lub pomiary.", + "thinkingElapsed": "{seconds}s", + "thinkingDuration": "Myślał przez {seconds}s", + "thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę", + "thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.", + "tokensUsed": "{count} tokenów", + "tokensUsedWithModel": "{count} tokenów · {model}" }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 1e650e5db..aa20016d2 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -187,7 +187,12 @@ describe("", () => { ).length; expect(userBubbles).toBe(1); expect(assistantBubbles).toBe(2); - expect(html).toContain("Looking at your data"); + // v1.18.9 — the live streaming turn renders each word in its own + // fade-in (``), so the prose is no longer one + // contiguous text node; assert each word is present span-wrapped. + for (const word of ["Looking ", "at ", "your ", "data"]) { + expect(html).toContain(`>${word}`); + } }); it("shows the streaming-only bubble when no conversation is loaded yet", () => { diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 65d26a33b..add50de79 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -20,12 +20,14 @@ import { cn } from "@/lib/utils"; import { scrollBehaviorForUser } from "@/lib/motion"; import { useTranslations } from "@/lib/i18n/context"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; -import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { useAuth } from "@/hooks/use-auth"; import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me"; import { SourceChips } from "./source-chips"; import { ReminderSuggestionCard } from "./reminder-suggestion-card"; +import { StreamedProse } from "./streamed-prose"; +import { ThinkingDisclosure } from "./thinking-disclosure"; +import { MessageTokenFooter } from "./message-token-footer"; import type { CoachConversationDetailDTO, CoachOptimisticUserMessage, @@ -422,6 +424,8 @@ export function MessageThread({ metricSource={m.metricSource} providerType={m.providerType} messageId={m.id} + tokensUsed={m.tokensUsed} + model={m.model} /> ); @@ -452,8 +456,10 @@ export function MessageThread({ aria-live="polite" aria-relevant="additions text" // v1.18.7 — a soft fade/slide-in on the assistant turn so the - // hand-off from the "Thinking…" beat to the first streamed - // tokens reads as one continuous motion, not a hard swap. + // hand-off from the thinking beat to the first streamed tokens + // reads as one continuous motion, not a hard swap. v1.18.9 — the + // per-word fade now lives in ; this stays a light + // container fade for the disclosure → prose transition. className="motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-300" > )} @@ -495,6 +507,35 @@ interface ChatBubbleProps { * user can't rate before the message lands on disk. */ messageId?: string; + /** + * v1.18.9 — true for the live streaming assistant turn. Drives the + * word-by-word prose fade (``); persisted bubbles render + * settled plain text. + */ + streaming?: boolean; + /** + * v1.18.9 — send-anchored timestamp (epoch ms) for the streaming turn, + * feeding the thinking disclosure's elapsed timer. Null on persisted + * bubbles. + */ + startedAt?: number | null; + /** + * v1.18.9 — reasoning-summary text from the additive `reasoning` SSE + * frame, shown inside the thinking disclosure when present. + */ + reasoning?: string; + /** + * v1.18.9 — per-turn token usage for the just-finished streaming bubble + * (from `done.usage`). Persisted bubbles read `tokensUsed` / `model` + * instead. + */ + usage?: import("./use-coach").CoachUsage | null; + /** + * v1.18.9 — persisted per-message token count + model, for the quiet + * token footer on reload. Null on user turns, refusals, and older rows. + */ + tokensUsed?: number | null; + model?: string | null; } function ChatBubble({ @@ -506,6 +547,12 @@ function ChatBubble({ inProgress, errorCode, messageId, + streaming, + startedAt, + reasoning, + usage, + tokensUsed, + model, }: ChatBubbleProps) { const { t } = useTranslations(); const { user } = useAuth(); @@ -631,38 +678,53 @@ function ChatBubble({ )}
-
- {/* v1.4.25 W5b — strip stray Metric/enum leak tokens from - the assistant prose before it lands in the bubble. The - raw `content` is the streamed Coach reply (or the - persisted twin after `done` fires); both paths are AI- - authored so they share the same leak surface as the - insight prose elsewhere. - - v1.16.1 — before the first token lands, the bubble shows - a classic typing indicator (three pulsing dots) instead - of the old whole-bubble pulse + "Thinking…" prose. The - prose stays as the screen-reader announcement. */} - {/* v1.16.4 — a failed turn with no streamed prose renders the - error copy INSIDE the bubble instead of an empty bubble - with a caption below it. With partial prose the prose - keeps the bubble and the error stays a caption. */} - {content ? ( - stripChartTokens(content) - ) : inProgress ? ( - - ) : safeError ? ( - {safeError} - ) : ( - "" - )} -
+ {/* v1.18.9 — the single thinking indicator (live elapsed-time line, + then an auto-collapsed past-tense disclosure). Renders only for + the live streaming turn — persisted bubbles pass no `startedAt`, + so the disclosure returns null and history stays settled. This + replaces the old dots-plus-word combo: there is + never a three-dots indicator AND a "Thinking" word at once. */} + {streaming && startedAt != null && ( + + )} + {/* The prose bubble. Skipped while the live turn is still thinking + with no prose (the disclosure carries the live state above), so + the user never sees an empty bubble under the indicator. The + `` fallback below only fires when there is no live + disclosure (e.g. a defensive null `startedAt`). */} + {(content || + safeError || + (inProgress && !(streaming && startedAt != null))) && ( +
+ {/* v1.4.25 W5b — strip stray Metric/enum leak tokens from the + assistant prose. v1.18.9 — the live turn streams in + word-by-word with a soft fade (); a settled + / persisted turn renders as plain text. */} + {/* v1.16.4 — a failed turn with no streamed prose renders the + error copy INSIDE the bubble; with partial prose the prose + keeps the bubble and the error stays a caption. */} + {content ? ( + + ) : inProgress ? ( + + ) : safeError ? ( + {safeError} + ) : ( + "" + )} +
+ )} {safeError && content && (

{safeError}

)} @@ -784,6 +846,16 @@ function ChatBubble({ const sug = suggestion ?? metricSource?.suggestion ?? null; return sug ? : null; })()} + {/* v1.18.9 — quiet per-message token footer. The just-finished + streaming turn reads the `done.usage` envelope; a persisted / + reloaded turn reads the message's own `tokensUsed` + `model`. + Skipped on in-flight, errored, and refusal turns. */} + {!inProgress && !errorCode && providerType !== "refusal" && ( + + )} {/* v1.4.23 H7 — per-message thumbs feedback. Only persisted assistant messages get the row (skipped for refusals, errors, in-flight stream bubbles). The aggregator buckets diff --git a/src/components/insights/coach-panel/message-token-footer.tsx b/src/components/insights/coach-panel/message-token-footer.tsx new file mode 100644 index 000000000..f6b17fe6c --- /dev/null +++ b/src/components/insights/coach-panel/message-token-footer.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.18.9 — quiet per-message token-usage caption under an assistant + * bubble. Server-authoritative: it renders the count the server reported + * (`done.usage.totalTokens` for the live turn, `CoachMessageDTO.tokensUsed` + * on reload), never a client-side recomputation. No cost — BYOK users pay + * the provider directly and a price table would only drift. + * + * Renders nothing when there is no count (user turns, refusals, in-flight + * bubbles, and pre-feature persisted rows all carry null). + */ +export interface MessageTokenFooterProps { + tokens: number | null | undefined; + /** Provider model name (e.g. `gpt-4o`); appended when present. */ + model?: string | null; +} + +export function MessageTokenFooter({ tokens, model }: MessageTokenFooterProps) { + const { t } = useTranslations(); + if (tokens == null) return null; + + const label = model + ? t("insights.coach.tokensUsedWithModel", { count: tokens, model }) + : t("insights.coach.tokensUsed", { count: tokens }); + + return ( +

+ {label} +

+ ); +} diff --git a/src/components/insights/coach-panel/streamed-prose.tsx b/src/components/insights/coach-panel/streamed-prose.tsx new file mode 100644 index 000000000..370d70a43 --- /dev/null +++ b/src/components/insights/coach-panel/streamed-prose.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useMemo } from "react"; + +import { cn } from "@/lib/utils"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; + +/** + * v1.18.9 — Claude-style word-by-word fade-in for the LIVE Coach turn. + * + * The provider clients return the whole reply in one shot; the chat route + * re-chunks it into per-word `token` SSE frames (`tokeniseForStreaming`), + * so `content` grows as frames land. This component renders each + * whitespace segment as its own `` that fades + drifts up on mount, + * giving the reply a "thinking out loud" cadence rather than a hard dump + * of word groups. Once the turn settles (`streaming === false`) the spans + * collapse to a single plain-text node — zero residual animation DOM, and + * the feedback / evidence layout underneath stays stable. + * + * Why no "only animate the new words" bookkeeping: each segment is keyed + * by its index, so when a fresh SSE frame appends words, React keeps the + * already-mounted spans (their CSS entrance already ran and does not + * replay on reconcile) and only MOUNTS the new trailing spans — which is + * exactly when the `animate-in` entrance fires. The browser does the + * "new words only" gating for us; no render-time ref is needed. + * + * CSP-safe: the entrance is Tailwind's `animate-in fade-in-0 + * slide-in-from-bottom-1` (compiled utilities served from `'self'`) plus a + * per-span inline `animationDelay` / `animationDuration`. Inline `style` + * attributes are already permitted under the strict CSP + * (`style-src 'self' 'unsafe-inline'`); there is no runtime-injected + * `