diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1138e83df..0005a232d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -16654,9 +16654,16 @@ components: unread: type: boolean description: True when that message is newer than the last time the caller opened the Coach. + conversationId: + anyOf: + - type: string + - type: "null" + description: Conversation holding the newest assistant message; null when none exists. The client deep-links into it + (`/coach?c=`) on the unread path. required: - nudgedAt - unread + - conversationId additionalProperties: false error: type: "null" diff --git a/messages/de.json b/messages/de.json index fc469ffe7..c0247a8cf 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2099,6 +2099,9 @@ "composerPlaceholder": "Frag mich etwas zu deinen Daten…", "nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen", "fabLabel": "Coach öffnen", + "readAloud": "Vorlesen", + "readAloudStop": "Vorlesen beenden", + "messageTimeLabel": "Nachricht gesendet {time}", "selfContextAdopt": { "offer": "Antwort in deine Selbstauskunft übernehmen?", "confirm": "Übernehmen", diff --git a/messages/en.json b/messages/en.json index c02b890d4..e62b0ffd7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2099,6 +2099,9 @@ "composerPlaceholder": "Ask anything about your data…", "nudgeBubbleLabel": "New message from the Coach — open chat", "fabLabel": "Open the Coach", + "readAloud": "Read aloud", + "readAloudStop": "Stop reading", + "messageTimeLabel": "Message sent {time}", "selfContextAdopt": { "offer": "Add this answer to your self-context?", "confirm": "Add", diff --git a/src/app/api/insights/coach/nudge-status/route.ts b/src/app/api/insights/coach/nudge-status/route.ts index 48d9d58c6..13653ac7d 100644 --- a/src/app/api/insights/coach/nudge-status/route.ts +++ b/src/app/api/insights/coach/nudge-status/route.ts @@ -39,7 +39,11 @@ export const GET = apiHandler(async () => { prisma.coachMessage.findFirst({ where: { role: "assistant", conversation: { userId: user.id } }, orderBy: { createdAt: "desc" }, - select: { createdAt: true }, + // v1.22 (W5) — also return the owning conversation id so the FAB can + // deep-link straight into the proactive thread (`/coach?c=`) + // instead of opening a blank new chat and relying on a most-recent + // heuristic to find it. + select: { createdAt: true, conversationId: true }, }), prisma.user.findUnique({ where: { id: user.id }, @@ -55,6 +59,10 @@ export const GET = apiHandler(async () => { return apiSuccess({ nudgedAt: nudgedAt ? nudgedAt.toISOString() : null, unread, + // The conversation holding the newest assistant message. Present + // whenever a Coach reply exists; the FAB only consumes it on the + // unread path. + conversationId: lastAssistant?.conversationId ?? null, }); }); diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx index 767dca039..5ad010ae3 100644 --- a/src/components/insights/__tests__/layout-coach-fab.test.tsx +++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx @@ -171,20 +171,42 @@ describe("isNudgeUnread", () => { it("is false without status or without a nudge", () => { expect(isNudgeUnread(undefined, null)).toBe(false); - expect(isNudgeUnread({ nudgedAt: null, unread: false }, null)).toBe(false); + expect( + isNudgeUnread( + { nudgedAt: null, unread: false, conversationId: null }, + null, + ), + ).toBe(false); }); it("is true for a server-unread nudge the device has not seen", () => { - expect(isNudgeUnread({ nudgedAt, unread: true }, null)).toBe(true); - expect(isNudgeUnread({ nudgedAt, unread: true }, "2026-01-01")).toBe(true); + expect( + isNudgeUnread({ nudgedAt, unread: true, conversationId: "conv-1" }, null), + ).toBe(true); + expect( + isNudgeUnread( + { nudgedAt, unread: true, conversationId: "conv-1" }, + "2026-01-01", + ), + ).toBe(true); }); it("is false once the server counts the nudge as read", () => { // The user sent a Coach message after the nudge. - expect(isNudgeUnread({ nudgedAt, unread: false }, null)).toBe(false); + expect( + isNudgeUnread( + { nudgedAt, unread: false, conversationId: "conv-1" }, + null, + ), + ).toBe(false); }); it("is false once this device stored the matching seen stamp", () => { - expect(isNudgeUnread({ nudgedAt, unread: true }, nudgedAt)).toBe(false); + expect( + isNudgeUnread( + { nudgedAt, unread: true, conversationId: "conv-1" }, + nudgedAt, + ), + ).toBe(false); }); }); diff --git a/src/components/insights/__tests__/prose-blocks.test.tsx b/src/components/insights/__tests__/prose-blocks.test.tsx new file mode 100644 index 000000000..65c20860d --- /dev/null +++ b/src/components/insights/__tests__/prose-blocks.test.tsx @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + ProseBlocks, + splitParagraphs, +} from "@/components/insights/prose-blocks"; +import { + StreamedProse, + splitProseSegments, +} from "@/components/insights/coach-panel/streamed-prose"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("splitParagraphs", () => { + it("splits on blank lines and trims, dropping empties", () => { + expect(splitParagraphs("one\n\ntwo")).toEqual(["one", "two"]); + expect(splitParagraphs(" a \n\n\n b ")).toEqual(["a", "b"]); + expect(splitParagraphs("solo")).toEqual(["solo"]); + expect(splitParagraphs("\n\n \n\n")).toEqual([]); + }); + + it("keeps a single newline inside one paragraph", () => { + expect(splitParagraphs("line1\nline2")).toEqual(["line1\nline2"]); + }); +}); + +describe("ProseBlocks", () => { + it("renders blank-line-separated text as real

blocks", () => { + const html = render(); + const paras = (html.match(/", () => { + const html = render(); + expect(html).toContain("
"); + expect((html.match(/ { + const html = render( + , + ); + expect(html).not.toContain("metric:WEIGHT"); + expect(html).toContain("Your weight"); + }); + + it("linkifies a catalog-known /learn/ reference as a safe anchor", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="inline-learn-link"'); + expect(html).toContain("/learn/resting-heart-rate"); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noopener noreferrer"'); + }); + + it("leaves an unknown /learn slug as plain text (fail-closed)", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="inline-learn-link"'); + expect(html).toContain("/learn/totally-made-up"); + }); + + it("does not strip or linkify when both are disabled (user text)", () => { + const html = render( + , + ); + expect(html).toContain("metric:WEIGHT"); + expect(html).not.toContain('data-slot="inline-learn-link"'); + }); +}); + +describe("StreamedProse", () => { + it("settles to real paragraph blocks when not streaming", () => { + const html = render( + , + ); + expect((html.match(/ { + const html = render( + , + ); + // The completed first paragraph settles as plain text (no spans). + expect(html).toContain("Settled paragraph."); + // The tail renders each word in its own fade-in span. + for (const word of ["Growing ", "tail ", "words"]) { + expect(html).toContain(`>${word}`); + } + }); + + it("does not leak a chart token into streamed prose", () => { + const html = render( + , + ); + expect(html).not.toContain("metric:WEIGHT"); + }); +}); + +describe("splitProseSegments", () => { + it("keeps each word with its trailing whitespace", () => { + expect(splitProseSegments("a b c")).toEqual(["a ", "b ", "c"]); + expect(splitProseSegments("")).toEqual([]); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/coach-charts.test.tsx b/src/components/insights/coach-panel/__tests__/coach-charts.test.tsx new file mode 100644 index 000000000..6de0a035e --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-charts.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +// Mirror the message-thread test harness: SSR render with no providers, so +// stub the hooks the bubble reaches for. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "tester", role: "USER", avatarUrl: null }, + isAuthenticated: true, + isLoading: false, + }), +})); +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined }), + useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }), +})); + +import { MessageThread, selectCoachChartTokens } from "../message-thread"; +import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("selectCoachChartTokens", () => { + it("returns an allowlisted token grounded in the turn's provenance", () => { + expect( + selectCoachChartTokens("The trend metric:WEIGHT is up", ["weight"]), + ).toEqual(["metric:WEIGHT"]); + }); + + it("drops a token whose metric is not in the grounded provenance", () => { + expect(selectCoachChartTokens("metric:WEIGHT", [])).toEqual([]); + expect(selectCoachChartTokens("metric:WEIGHT", ["pulse"])).toEqual([]); + }); + + it("drops a hallucinated / non-allowlisted token", () => { + expect(selectCoachChartTokens("metric:NUKE", ["weight"])).toEqual([]); + }); + + it("omits synthetic tokens without a self-fetching chart (MOOD)", () => { + expect(selectCoachChartTokens("metric:MOOD", ["mood"])).toEqual([]); + }); + + it("de-duplicates by metric and caps the number rendered", () => { + const tokens = selectCoachChartTokens( + "metric:WEIGHT metric:WEIGHT metric:PULSE metric:BLOOD_GLUCOSE", + ["weight", "pulse", "glucose"], + ); + expect(tokens).toEqual(["metric:WEIGHT", "metric:PULSE"]); + }); +}); + +describe("Coach chart rendering", () => { + const conversation: CoachConversationDetailDTO = { + id: "c1", + title: "Test", + createdAt: "2026-06-01T10:00:00.000Z", + updatedAt: "2026-06-01T10:00:00.000Z", + messageCount: 1, + messages: [ + { + id: "m1", + role: "assistant", + content: "Your weight is trending down. metric:WEIGHT", + createdAt: "2026-06-01T10:00:00.000Z", + metricSource: { windows: ["last30days"], metrics: ["weight"] }, + providerType: "openai", + promptVersion: null, + tokensUsed: null, + model: null, + }, + ], + }; + + it("mounts a chart for a grounded token and strips it from the prose", () => { + const html = render(); + expect(html).toContain('data-slot="coach-charts"'); + expect(html).toContain('data-slot="coach-chart"'); + // The literal token must never surface in the rendered prose. + expect(html).not.toContain("metric:WEIGHT"); + expect(html).toContain("Your weight is trending down"); + }); + + it("renders no chart when the token is not grounded in provenance", () => { + const ungrounded: CoachConversationDetailDTO = { + ...conversation, + messages: [ + { + ...conversation.messages[0], + metricSource: { windows: [], metrics: ["pulse"] }, + }, + ], + }; + const html = render(); + expect(html).not.toContain('data-slot="coach-chart"'); + }); +}); diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 088b83235..2082b8562 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -1,6 +1,15 @@ "use client"; -import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react"; +import { + Fragment, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import Link from "next/link"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -9,19 +18,30 @@ import { Bot, Check, ChevronRight, + Clock, Loader2, Sparkles, ThumbsDown, ThumbsUp, User, + Volume2, + VolumeX, } from "lucide-react"; import { cn } from "@/lib/utils"; import { scrollBehaviorForUser } from "@/lib/motion"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { useAuth } from "@/hooks/use-auth"; import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me"; +import { + parseChartTokens, + stripChartTokens, + tokenToMetric, + type ChartToken, +} from "@/lib/insights/chart-tokens"; +import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; +import { ProseBlocks } from "@/components/insights/prose-blocks"; import { SourceChips } from "./source-chips"; import { ReminderSuggestionCard } from "./reminder-suggestion-card"; @@ -32,7 +52,10 @@ import type { CoachOptimisticUserMessage, CoachStreamingMessage, } from "./use-coach"; -import type { CoachMessageDTO } from "@/lib/ai/coach/types"; +import type { + CoachMessageDTO, + CoachProvenanceMetric, +} from "@/lib/ai/coach/types"; /** * v1.4.20 phase B2b — message-thread renderer. @@ -209,6 +232,282 @@ export function errorCodeToI18nKey(code: string): string { } } +/** + * v1.22 (W5) — Coach accompanying charts, Phase 1: the renderer half. + * + * The chart-token mechanism (`chart-tokens.ts`) was prepared for the + * Insights prose but its render path was never wired for the Coach — + * `stripChartTokens` cleaned tokens out of the prose, but `parseChartTokens` + * had no caller, so a `metric:` token the model emitted rendered no + * chart. This map activates the render half for the Coach. + * + * Each entry pairs an allowlisted chart token with the Coach provenance + * TOPIC that the snapshot stamps when it actually drew on that metric. A + * chart only renders when its topic is present in `metricSource.metrics`, so + * the series is grounded twice over: the closed allowlist drops a + * hallucinated token, and the provenance intersect drops a metric the turn + * never saw (and which therefore has no data). The chart itself self-fetches + * the user's real series from `/api/measurements` — the model never emits a + * data point. + * + * Only MeasurementType-backed tokens that render through the generic, + * self-fetching `` are listed. The synthetic `metric:MOOD` + * token (served by a separate ``) and the allowlist's reserved + * score classes are intentionally omitted from Phase 1. + * + * The prompt clause that tells the model it MAY emit one such token is a + * separate concern (the narrative/prompt workstream); this is render-only, + * provider-agnostic (it reads the plain inline token, so it works for every + * provider including the codex inline-text path), and a graceful no-op when + * no grounded token is present. + */ +const CHART_TOKEN_PROVENANCE: Partial< + Record +> = { + "metric:WEIGHT": "weight", + "metric:BLOOD_PRESSURE_SYS": "bp", + "metric:BLOOD_PRESSURE_DIA": "bp", + "metric:PULSE": "pulse", + "metric:BODY_FAT": "body_fat", + "metric:SLEEP_DURATION": "sleep", + "metric:ACTIVITY_STEPS": "steps", + "metric:BLOOD_GLUCOSE": "glucose", + "metric:TOTAL_BODY_WATER": "total_body_water", + "metric:BONE_MASS": "bone_mass", + "metric:OXYGEN_SATURATION": "spo2", + "metric:HEART_RATE_VARIABILITY": "hrv", + "metric:RESTING_HEART_RATE": "resting_hr", + "metric:ACTIVE_ENERGY_BURNED": "active_energy", + "metric:FLIGHTS_CLIMBED": "flights", + "metric:WALKING_RUNNING_DISTANCE": "distance", + "metric:VO2_MAX": "vo2_max", + "metric:BODY_TEMPERATURE": "body_temp", + "metric:FAT_FREE_MASS": "fat_free_mass", + "metric:FAT_MASS": "fat_mass", + "metric:MUSCLE_MASS": "muscle_mass", + "metric:LEAN_BODY_MASS": "lean_body_mass", + "metric:BODY_MASS_INDEX": "bmi", + "metric:VISCERAL_FAT": "visceral_fat", + "metric:SKIN_TEMPERATURE": "skin_temp", + "metric:RESPIRATORY_RATE": "respiratory_rate", + "metric:PULSE_WAVE_VELOCITY": "pulse_wave_velocity", + "metric:VASCULAR_AGE": "vascular_age", + "metric:WALKING_HEART_RATE_AVERAGE": "walking_hr", + "metric:WALKING_ASYMMETRY": "walking_asymmetry", + "metric:WALKING_DOUBLE_SUPPORT": "walking_double_support", + "metric:WALKING_STEP_LENGTH": "walking_step_length", + "metric:WALKING_SPEED": "walking_speed", + "metric:AUDIO_EXPOSURE_ENV": "audio_env", + "metric:AUDIO_EXPOSURE_HEADPHONE": "audio_headphone", + "metric:AUDIO_EXPOSURE_EVENT": "audio_event", + "metric:TIME_IN_DAYLIGHT": "daylight", +}; + +/** Max charts rendered under a single Coach turn (keeps the reply scannable). */ +const MAX_COACH_CHARTS = 2; + +/** + * Pure selection of the chart tokens to render under an assistant turn: + * allowlist-parsed, intersected with the turn's grounded provenance topics, + * de-duplicated by metric, and capped. Exported for unit tests so the + * grounding contract is pinned without standing up the chart component. + */ +export function selectCoachChartTokens( + content: string, + metrics: readonly CoachProvenanceMetric[] | undefined, +): ChartToken[] { + const grounded = new Set(metrics ?? []); + const out: ChartToken[] = []; + const seen = new Set(); + for (const token of parseChartTokens(content)) { + const topic = CHART_TOKEN_PROVENANCE[token]; + if (!topic || !grounded.has(topic)) continue; + const metric = tokenToMetric(token); + if (seen.has(metric)) continue; + seen.add(metric); + out.push(token); + if (out.length >= MAX_COACH_CHARTS) break; + } + return out; +} + +/** + * Chart tokens whose canonical measurement-type label has no dedicated + * `measurements.type*` key — route them to the closest existing key so the + * chart header reads cleanly rather than echoing the raw enum. + */ +const CHART_TITLE_KEY_OVERRIDE: Record = { + BLOOD_PRESSURE_SYS: "measurements.typeBloodPressure", + BLOOD_PRESSURE_DIA: "measurements.typeBloodPressure", +}; + +/** Localised chart header for a MeasurementType, mirroring the snapshot's + * `measurements.type` convention with a readable fallback. */ +function coachChartTitle( + metric: string, + t: (key: string, vars?: Record) => string, +): string { + const override = CHART_TITLE_KEY_OVERRIDE[metric]; + if (override) { + const resolved = t(override); + if (resolved !== override) return resolved; + } + const camel = metric + .toLowerCase() + .split("_") + .map((part, i) => + i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1), + ) + .join(""); + const key = `measurements.type${camel.charAt(0).toUpperCase()}${camel.slice(1)}`; + const resolved = t(key); + return resolved === key ? metric.replace(/_/g, " ").toLowerCase() : resolved; +} + +/** + * v1.22 (W5) — feature-detect the browser Speech Synthesis API SSR-safe. + * `useSyncExternalStore` returns false on the server + first client paint + * (so the read-aloud button is absent in markup) and resolves to the real + * capability after hydration, mirroring the mic button's detection pattern + * — no hydration mismatch warning, no `connect-src` (it runs in-browser). + */ +function useSpeechSynthesisSupported(): boolean { + return useSyncExternalStore( + () => () => {}, + () => + typeof window !== "undefined" && + "speechSynthesis" in window && + "SpeechSynthesisUtterance" in window, + () => false, + ); +} + +/** + * v1.22 (W5) — read the assistant prose aloud via the browser's + * `SpeechSynthesis`. Client-only, no dependency, no egress, CSP-clean. A + * single in-flight utterance: a new `speak` cancels any prior one; `toggle` + * stops while speaking. The utterance language follows the active UI locale + * so the browser picks the closest installed voice (best-effort by OS). + */ +function useReadAloud(): { + supported: boolean; + speaking: boolean; + toggle: (text: string, lang: string) => void; +} { + const supported = useSpeechSynthesisSupported(); + const [speaking, setSpeaking] = useState(false); + + // Stop any in-flight utterance if the bubble unmounts (thread reset, nav). + useEffect(() => { + return () => { + if (typeof window !== "undefined" && "speechSynthesis" in window) { + window.speechSynthesis.cancel(); + } + }; + }, []); + + const toggle = useCallback( + (text: string, lang: string) => { + if (!supported) return; + const synth = window.speechSynthesis; + if (speaking) { + synth.cancel(); + setSpeaking(false); + return; + } + const trimmed = text.trim(); + if (!trimmed) return; + synth.cancel(); + const utterance = new SpeechSynthesisUtterance(trimmed); + utterance.lang = lang; + utterance.onend = () => setSpeaking(false); + utterance.onerror = () => setSpeaking(false); + setSpeaking(true); + synth.speak(utterance); + }, + [supported, speaking], + ); + + return { supported, speaking, toggle }; +} + +/** + * v1.22 (W5) — read-aloud toggle for a settled assistant turn. Hidden when + * the browser has no Speech Synthesis support. Speaks `stripChartTokens` + * (the same text the prose shows), so stray tokens are never voiced. + */ +function ReadAloudButton({ content }: { content: string }) { + const { t, locale } = useTranslations(); + const { supported, speaking, toggle } = useReadAloud(); + if (!supported) return null; + const label = speaking + ? t("insights.coach.readAloudStop") + : t("insights.coach.readAloud"); + return ( + + ); +} + +/** + * v1.22 (W5) — hover/tap timestamp for a persisted message bubble. A small, + * calm clock affordance; the locale-aware date + time surfaces in a tooltip + * on hover or keyboard focus (desktop) and on tap (mobile, toggled state). + * Pure CSS + one boolean — no portal, no Radix dependency, SSR-safe. + */ +function BubbleTimestamp({ + iso, + align = "start", +}: { + iso: string; + align?: "start" | "end"; +}) { + const { t } = useTranslations(); + const formatters = useFormatters(); + const [open, setOpen] = useState(false); + const label = formatters.dateTime(iso); + return ( + + + + {label} + + + ); +} + export function MessageThread({ conversation, streaming, @@ -425,6 +724,7 @@ export function MessageThread({ messageId={m.id} tokensUsed={m.tokensUsed} model={m.model} + createdAt={m.createdAt} /> ); @@ -521,6 +821,12 @@ interface ChatBubbleProps { */ tokensUsed?: number | null; model?: string | null; + /** + * v1.22 (W5) — ISO creation timestamp for the persisted bubble's + * hover/tap timestamp tooltip. Absent on optimistic + streaming bubbles + * (no persisted time yet), so those render no timestamp. + */ + createdAt?: string; } function ChatBubble({ @@ -536,6 +842,7 @@ function ChatBubble({ usage, tokensUsed, model, + createdAt, }: ChatBubbleProps) { const { t } = useTranslations(); const { user } = useAuth(); @@ -580,8 +887,12 @@ function ChatBubble({ "text-sm leading-relaxed", )} > - {content} + {/* v1.22 (W5) — render real paragraph blocks so a multi-line + message reads as paragraphs, not one run-on block. User text + is verbatim: no chart-token strip, no Learn linkify. */} + + {createdAt && } {/* v1.16.8 — explicit remember control. Stating an allergy in chat used to leave no durable trace unless a narrow pattern pass happened to match the phrasing; this stores @@ -645,6 +956,15 @@ function ChatBubble({ (metricSource.windows?.length ?? 0) > 0); const hasProvenance = hasChips || keyValues.length > 0; + // v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance- + // grounded `metric:` chart under a SETTLED assistant turn. Skipped + // while streaming / in-flight / errored / on a refusal; a no-op when no + // grounded token is present (provider-agnostic — reads the inline token). + const chartTokens = + !streaming && !inProgress && !errorCode && providerType !== "refusal" + ? selectCoachChartTokens(content, metricSource?.metrics) + : []; + return (

{safeError}

)} + {/* v1.22 (W5) — accompanying chart(s). The token was already + stripped from the prose by ; here it mounts the + real, self-fetching Recharts chart for the user's own series. + The model never emits data — only a metric identifier from a + closed allowlist, intersected with the turn's grounded metrics. */} + {chartTokens.length > 0 && ( +
+ {chartTokens.map((token) => { + const metric = tokenToMetric(token); + return ( +
+ +
+ ); + })} +
+ )} {/* v1.18.6 — a "no provider configured anywhere" turn is a setup gap, not a transient failure: surface a direct link to Settings → AI so the Coach guides the user into BYOK / local @@ -829,14 +1173,19 @@ function ChatBubble({ )} {/* 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 - the rating by (promptVersion, tone, verbosity). */} - {messageId && - !inProgress && - !errorCode && - providerType !== "refusal" && ( - - )} + errors, in-flight stream bubbles). v1.22 (W5) — the + read-aloud toggle joins the same action row on a settled turn; + the bubble timestamp trails it. */} + {!inProgress && !errorCode && providerType !== "refusal" && content && ( +
+ {!streaming && } + {messageId && } + {createdAt && } +
+ )}
); diff --git a/src/components/insights/coach-panel/streamed-prose.tsx b/src/components/insights/coach-panel/streamed-prose.tsx index 370d70a43..7b41f4df0 100644 --- a/src/components/insights/coach-panel/streamed-prose.tsx +++ b/src/components/insights/coach-panel/streamed-prose.tsx @@ -4,22 +4,37 @@ import { useMemo } from "react"; import { cn } from "@/lib/utils"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; +import { + ProseBlocks, + ParagraphText, + splitParagraphs, + PROSE_PARAGRAPH_CLASS, +} from "@/components/insights/prose-blocks"; /** * 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. + * so `content` grows as frames land. While the turn is streaming each + * whitespace segment of the GROWING tail renders 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. * - * 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 + * v1.22 (W5) — paragraph-aware + SSE-safe. The reply now renders as real + * `

` blocks (shared `ProseBlocks`), so a model that emits blank-line + * paragraph breaks reads as paragraphs, not one run-on block — consistent + * with the daily briefing + insight cards. Under streaming, every COMPLETE + * paragraph settles immediately (plain text, with the safe Learn linkifier) + * and only the LAST, still-growing paragraph animates word-by-word. When a + * fresh `\n\n` arrives the tail becomes a completed paragraph and stops + * animating; the already-settled paragraphs keep their index keys and never + * replay. Once the whole turn settles (`streaming === false`) it collapses + * to the same `ProseBlocks` render the persisted history uses. + * + * Why no "only animate the new words" bookkeeping: each tail 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. @@ -30,8 +45,8 @@ import { stripChartTokens } from "@/lib/insights/chart-tokens"; * attributes are already permitted under the strict CSP * (`style-src 'self' 'unsafe-inline'`); there is no runtime-injected * `