feat(coach): word-fade stream, thinking disclosure, token footer

Stream the assistant turn word-by-word with a soft motion-safe fade
(StreamedProse), reduced-motion collapses to instant. Replace the
dots-plus-word placeholder with a single thinking indicator: a live
elapsed-time line that freezes into an auto-collapsed past-tense
disclosure, which surfaces a reasoning summary when one is present and a
factual fallback otherwise — never both a spinner and a Thinking word at
once. Add a quiet per-message token footer (count plus model, no cost)
fed by the live done.usage frame and persisted tokensUsed on reload.
Add the localised strings across all six bundles.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 12:05:31 +02:00
1 parent f46e9d3bb8
commit 88ee5bb4f8
11 files changed
+438 -42

No files matched your search

+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
@@ -187,7 +187,12 @@ describe("<MessageThread>", () => {
).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 <span> (`<StreamedProse>`), 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}</span>`);
}
});
it("shows the streaming-only bubble when no conversation is loaded yet", () => {
@@ -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}
/>
</Fragment>
);
@@ -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 <StreamedProse>; 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"
>
<ChatBubble
@@ -464,6 +470,12 @@ export function MessageThread({
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
// v1.18.9 — live word-fade + the elapsed-time thinking
// disclosure + the just-landed token footer.
streaming
startedAt={streaming.startedAt}
reasoning={streaming.reasoning}
usage={streaming.usage}
/>
</div>
)}
@@ -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 (`<StreamedProse>`); 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({
)}
</div>
<div className="flex max-w-[calc(80%-2.625rem)] flex-col gap-2">
<div
className={cn(
"border-border/60 bg-muted/40 text-foreground",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed whitespace-pre-wrap",
)}
>
{/* 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 ? (
<TypingDots label={t("insights.coach.thinking")} />
) : safeError ? (
<span className="text-warning/90">{safeError}</span>
) : (
""
)}
</div>
{/* 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 <TypingDots> dots-plus-word combo: there is
never a three-dots indicator AND a "Thinking" word at once. */}
{streaming && startedAt != null && (
<ThinkingDisclosure
startedAt={startedAt}
inProgress={!!inProgress}
hasContent={!!content}
reasoning={reasoning}
/>
)}
{/* 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
`<TypingDots>` fallback below only fires when there is no live
disclosure (e.g. a defensive null `startedAt`). */}
{(content ||
safeError ||
(inProgress && !(streaming && startedAt != null))) && (
<div
className={cn(
"border-border/60 bg-muted/40 text-foreground",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed whitespace-pre-wrap",
)}
>
{/* 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 (<StreamedProse>); 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 ? (
<StreamedProse content={content} streaming={!!streaming} />
) : inProgress ? (
<TypingDots label={t("insights.coach.thinking")} />
) : safeError ? (
<span className="text-warning/90">{safeError}</span>
) : (
""
)}
</div>
)}
{safeError && content && (
<p className="text-warning/90 text-xs">{safeError}</p>
)}
@@ -784,6 +846,16 @@ function ChatBubble({
const sug = suggestion ?? metricSource?.suggestion ?? null;
return sug ? <ReminderSuggestionCard suggestion={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" && (
<MessageTokenFooter
tokens={usage?.totalTokens ?? tokensUsed}
model={usage?.model ?? model}
/>
)}
{/* 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
@@ -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 (
<p
data-slot="coach-token-footer"
className="text-muted-foreground/80 text-[11px] tabular-nums"
>
{label}
</p>
);
}
@@ -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 `<span>` 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
* `<style>` tag, no `dangerouslySetInnerHTML`, and no markdown library —
* the text renders as plain React children, the XSS-safe path the rest of
* the Coach uses.
*
* `prefers-reduced-motion`: the animation classes are `motion-safe:`
* gated, so reduced-motion users get the words instantly with no fade and
* no translate.
*/
export interface StreamedProseProps {
/** Raw assistant prose (may contain stray chart-token leaks). */
content: string;
/**
* True while the turn is still streaming. When false the prose renders
* settled as one plain-text node (persisted history + finished turns).
*/
streaming: boolean;
}
/**
* Split prose into word+trailing-whitespace segments. Mirrors the route's
* server-side `tokeniseForStreaming` split (`\S+\s*`) so the client-side
* re-split lines up with how the SSE frames arrived. Exported for tests.
*/
export function splitProseSegments(text: string): string[] {
return text.match(/\S+\s*/g) ?? (text ? [text] : []);
}
export function StreamedProse({ content, streaming }: StreamedProseProps) {
const clean = stripChartTokens(content);
const segments = useMemo(
() => (streaming ? splitProseSegments(clean) : []),
[streaming, clean],
);
if (!streaming) {
// Settled turn: one plain text node, no spans, no animation.
return <>{clean}</>;
}
return (
<>
{segments.map((segment, index) => (
// New trailing spans mount + animate; already-mounted spans keep
// their stable index key and do not replay the entrance.
<span
key={index}
className={cn(
"motion-safe:animate-in motion-safe:fade-in-0",
"motion-safe:slide-in-from-bottom-1",
)}
style={{
animationDuration: "340ms",
// A gentle, capped stagger keyed off the in-batch position so a
// burst of words reads as a soft cascade rather than a single
// flash, without lagging on a long frame.
animationDelay: `${(index % 6) * 26}ms`,
animationFillMode: "both",
}}
>
{segment}
</span>
))}
</>
);
}
@@ -0,0 +1,138 @@
"use client";
import { useEffect, useId, useState } from "react";
import { ChevronRight, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.18.9 — the ONE Coach thinking indicator on the page surface.
*
* Replaces the old dots-plus-word combo (`<TypingDots>`), which showed
* three bouncing dots AND the word "Thinking…" at the same time — the
* exact pairing the maintainer disliked. This is a single, calm control:
*
* - While the turn is thinking (streaming, no prose yet): a live
* "Thinking… N s" line with a small spinner. `role="status"` so a
* screen reader announces "thinking" once, not on every tick.
* - Once the first token lands OR the turn settles: it relabels to the
* past tense ("Thought for N s") and auto-collapses into a quiet
* `<details>` disclosure. Expanding reveals the reasoning summary when
* a reasoning-capable provider supplied one (the additive `reasoning`
* SSE frame), otherwise a one-line factual fallback — never fabricated
* chain-of-thought.
*
* The elapsed time is computed from the send-anchored `startedAt`
* timestamp: it ticks live while thinking and freezes the instant the
* first token (or the settled state) arrives.
*/
export interface ThinkingDisclosureProps {
/**
* Epoch-ms timestamp the turn started (the moment the send fired). Null
* disables the elapsed timer (the component renders nothing).
*/
startedAt: number | null;
/** True until the turn settles (the `done`/`error` frame closed it). */
inProgress: boolean;
/**
* Whether any assistant prose has streamed in yet. Drives the live →
* past-tense relabel + auto-collapse: the moment the answer starts, the
* "Thinking…" line freezes and folds away.
*/
hasContent: boolean;
/**
* Optional reasoning-summary text from the additive `reasoning` SSE
* frame, rendered inside the expanded disclosure when present.
*/
reasoning?: string;
}
export function ThinkingDisclosure({
startedAt,
inProgress,
hasContent,
reasoning,
}: ThinkingDisclosureProps) {
const { t } = useTranslations();
const panelId = useId();
const [open, setOpen] = useState(false);
// The turn is "thinking" only while it is in progress with no prose yet.
const thinking = inProgress && hasContent === false && startedAt !== null;
// `elapsed` ticks once a second while thinking; the interval stops the
// moment `thinking` flips false, so the last value it set is the frozen
// "thought for N s" figure — no render-time ref, no sync setState in the
// effect body (the first tick lands on the interval, not inline).
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!thinking || startedAt === null) return;
const tick = () =>
setElapsed(Math.max(0, Math.round((Date.now() - startedAt) / 1000)));
tick();
const handle = window.setInterval(tick, 1000);
return () => window.clearInterval(handle);
}, [thinking, startedAt]);
if (startedAt === null) return null;
// ── Live "thinking" line (single indicator, no dots-plus-word) ──
if (thinking) {
return (
<div
data-slot="coach-thinking-live"
role="status"
aria-live="polite"
className="text-muted-foreground inline-flex items-center gap-2 py-0.5 text-xs"
>
<Loader2
aria-hidden="true"
className="text-dracula-purple/70 size-3.5 animate-spin motion-reduce:animate-none"
/>
<span>{t("insights.coach.thinking")}</span>
<span aria-hidden="true" className="tabular-nums opacity-70">
{t("insights.coach.thinkingElapsed", { seconds: elapsed })}
</span>
</div>
);
}
// ── Settled: past-tense collapsible disclosure (auto-collapsed) ──
const reasoningText = reasoning?.trim() ?? "";
return (
<details
data-slot="coach-thinking-disclosure"
open={open}
onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}
className="group inline-block"
>
<summary
data-slot="coach-thinking-summary"
aria-controls={panelId}
aria-expanded={open}
aria-label={t("insights.coach.thinkingExpandAria")}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex cursor-pointer items-center gap-1 text-xs",
"marker:hidden [&::-webkit-details-marker]:hidden",
"focus-visible:ring-ring/50 rounded outline-none focus-visible:ring-2",
)}
>
<ChevronRight
aria-hidden="true"
className="size-3 transition-transform group-open:rotate-90"
/>
<span>
{t("insights.coach.thinkingDuration", { seconds: elapsed })}
</span>
</summary>
<p
id={panelId}
data-slot="coach-thinking-detail"
className="text-muted-foreground mt-1.5 pl-4 text-xs leading-relaxed"
>
{reasoningText || t("insights.coach.thinkingDetail")}
</p>
</details>
);
}