From 16ab522a065778edd9236602c1c007cb33fd9282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:31:03 +0200 Subject: [PATCH] feat(coach): render AI prose as real paragraphs with clickable Learn links Add a shared, pure ProseBlocks helper that splits trusted plain text on blank lines into

blocks and single newlines into
, with no markdown library and no dangerouslySetInnerHTML. A catalog-whitelisted linkifier turns grounded /learn/ references into safe anchors via learn-more-link; unknown slugs stay plain text. Rewrite StreamedProse to be streaming-safe: completed paragraphs settle while only the growing tail animates word by word. --- .../insights/__tests__/prose-blocks.test.tsx | 130 +++++++++++++ .../insights/coach-panel/streamed-prose.tsx | 115 ++++++++---- src/components/insights/prose-blocks.tsx | 175 ++++++++++++++++++ src/components/ui/learn-more-link.tsx | 50 ++++- 4 files changed, 431 insertions(+), 39 deletions(-) create mode 100644 src/components/insights/__tests__/prose-blocks.test.tsx create mode 100644 src/components/insights/prose-blocks.tsx 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/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 * `