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 <p> blocks and single newlines into <br>, with no markdown
library and no dangerouslySetInnerHTML. A catalog-whitelisted linkifier turns
grounded /learn/<slug> 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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:31:03 +02:00
1 parent 7d3c528157
commit 16ab522a06
4 files changed
+431 -39

No files matched your search

@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
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 <p> blocks", () => {
const html = render(<ProseBlocks text={"First idea.\n\nSecond idea."} />);
const paras = (html.match(/<p\b/g) ?? []).length;
expect(paras).toBe(2);
expect(html).toContain("First idea.");
expect(html).toContain("Second idea.");
});
it("turns a single newline into a <br/>", () => {
const html = render(<ProseBlocks text={"line a\nline b"} />);
expect(html).toContain("<br/>");
expect((html.match(/<p\b/g) ?? []).length).toBe(1);
});
it("strips stray chart tokens from the prose by default", () => {
const html = render(
<ProseBlocks text={"Your weight metric:WEIGHT held."} />,
);
expect(html).not.toContain("metric:WEIGHT");
expect(html).toContain("Your weight");
});
it("linkifies a catalog-known /learn/<slug> reference as a safe anchor", () => {
const html = render(
<ProseBlocks
text={"More on this: https://healthlog.dev/learn/resting-heart-rate"}
/>,
);
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(
<ProseBlocks text={"see https://healthlog.dev/learn/totally-made-up"} />,
);
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(
<ProseBlocks
text={"keep metric:WEIGHT and /learn/resting-heart-rate verbatim"}
strip={false}
linkify={false}
/>,
);
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(
<StreamedProse
content={"Where things stand.\n\nOne thing to try."}
streaming={false}
/>,
);
expect((html.match(/<p\b/g) ?? []).length).toBe(2);
expect(html).toContain("Where things stand.");
expect(html).toContain("One thing to try.");
});
it("animates only the growing tail paragraph while streaming", () => {
const html = render(
<StreamedProse
content={"Settled paragraph.\n\nGrowing tail words"}
streaming
/>,
);
// 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}</span>`);
}
});
it("does not leak a chart token into streamed prose", () => {
const html = render(
<StreamedProse content={"Trending up metric:WEIGHT"} streaming />,
);
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([]);
});
});
@@ -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 `<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.
* so `content` grows as frames land. While the turn is streaming each
* whitespace segment of the GROWING tail renders 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.
*
* 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
* `<p>` 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
* `<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.
* the text renders as plain React children (with a closed-set Learn
* linkifier), 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
@@ -42,7 +57,7 @@ export interface StreamedProseProps {
content: string;
/**
* True while the turn is still streaming. When false the prose renders
* settled as one plain-text node (persisted history + finished turns).
* settled as real paragraph blocks (persisted history + finished turns).
*/
streaming: boolean;
}
@@ -57,40 +72,64 @@ export function splitProseSegments(text: string): string[] {
}
export function StreamedProse({ content, streaming }: StreamedProseProps) {
const clean = stripChartTokens(content);
const segments = useMemo(
() => (streaming ? splitProseSegments(clean) : []),
[streaming, clean],
// Split paragraphs on the RAW text — `stripChartTokens` normalises
// whitespace (collapsing the `\n\n` breaks), so it must run per line
// inside the renderers, never on the whole string before the split.
const paras = useMemo(
() => (streaming ? splitParagraphs(content) : []),
[streaming, content],
);
if (!streaming) {
// Settled turn: one plain text node, no spans, no animation.
return <>{clean}</>;
// Settled turn: real <p> blocks + per-line token strip + the safe Learn
// linkifier, the same render the persisted history uses.
return <ProseBlocks text={content} />;
}
if (paras.length === 0) return null;
const tail = paras[paras.length - 1];
const settled = paras.slice(0, -1);
// The growing tail animates word-by-word; strip its tokens here so a
// mid-stream `metric:<TYPE>` leak never flashes into the prose.
const tailSegments = splitProseSegments(stripChartTokens(tail));
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>
{/* Completed paragraphs settle immediately — plain text + linkifier,
identical to the non-streaming render so nothing reflows when the
turn finishes. */}
{settled.map((p, i) => (
<p key={i} className={cn(PROSE_PARAGRAPH_CLASS)}>
<ParagraphText text={p} strip />
</p>
))}
{/* The growing tail paragraph: word-by-word fade. No linkify here —
a half-arrived URL must not flicker into a link mid-stream; it
settles (and linkifies) once the turn finishes. */}
<p key={settled.length} className={cn(PROSE_PARAGRAPH_CLASS)}>
{tailSegments.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>
))}
</p>
</>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { Fragment, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { learnUrl } from "@/lib/learn-links";
import { InlineLearnLink } from "@/components/ui/learn-more-link";
/**
* v1.22 (W5) — shared paragraph renderer for every trusted-as-text AI
* narrative surface (the Coach, the daily briefing, the per-metric /
* score insight cards, the period narrative).
*
* The AI surfaces render their prose as plain React text children — there
* is NO markdown library and NO `dangerouslySetInnerHTML` anywhere in the
* tree (the project's XSS posture). Before this helper, three ad-hoc
* approaches were in the tree (a single `<p>`, a `whitespace-pre-line`
* `<p>`, and the Coach's word-span renderer), so a model that emitted real
* paragraph breaks read as one run-on block.
*
* `ProseBlocks` settles that into ONE pure, XSS-safe splitter:
* - split a TRUSTED plain string on blank lines into real `<p>` blocks;
* - a single `\n` inside a paragraph becomes a `<br/>`;
* - recognised `/learn/<slug>` references (catalog-whitelisted, already
* post-filtered server-side) render as a real, safe `<a>` via
* `InlineLearnLink` — a closed-set linkifier, never model HTML.
*
* Everything is `String.prototype.split` + React children — no parser, no
* markdown dependency, no injected markup. The chart-token strip
* (`stripChartTokens`) runs PER LINE (not on the whole string): it
* normalises intra-line whitespace, so running it on the full text would
* collapse the `\n\n` paragraph breaks before they can be split on. Splitting
* paragraphs first, then stripping each line, keeps the structure intact
* while still ensuring a stray `metric:<TYPE>` token never surfaces.
*/
/** Default paragraph styling shared by every prose surface + the Coach
* streaming tail, so the streamed and settled renders line up exactly. */
export const PROSE_PARAGRAPH_CLASS =
"leading-relaxed [&:not(:first-child)]:mt-3";
/**
* Split a trusted-as-text string into real paragraphs on blank lines.
* Whitespace-only fragments are dropped. Exported for the streaming
* paragraph-aware renderer + unit tests.
*/
export function splitParagraphs(text: string): string[] {
return text
.split(/\n{2,}/)
.map((s) => s.trim())
.filter(Boolean);
}
/**
* Catalog-whitelisted `/learn/<slug>` matcher. Mirrors the post-filter's
* grammar (`learn-link-guard.ts`): the host is optional (relative or the
* absolute `healthlog.dev/learn/<slug>` form), the slug is the lower-case
* hyphenated path segment after `/learn/`. The capture is validated against
* the published catalog (`learnUrl`) before an anchor renders — an unknown
* slug stays plain text, so a drifted post-filter can never produce a
* clickable invented link.
*/
const LEARN_URL_RE =
/(?:https?:\/\/)?(?:[a-z0-9.-]*healthlog\.dev)?\/learn\/[a-z0-9-]+\/?/gi;
/**
* Turn a trusted single line of text into React children, rendering any
* catalog-known `/learn/<slug>` reference as a safe `InlineLearnLink` and
* leaving everything else as plain text nodes. An unknown slug (which the
* server post-filter should already have stripped) falls through as plain
* text — fail-closed, never a fabricated anchor.
*/
function linkifyLearnLinks(line: string): ReactNode {
if (!line.includes("/learn/")) return line;
const nodes: ReactNode[] = [];
let lastIndex = 0;
let key = 0;
// `matchAll` over a global regex gives each match with its index so we
// slice the surrounding text verbatim — no mutation of the source.
for (const match of line.matchAll(LEARN_URL_RE)) {
const matched = match[0];
const start = match.index ?? 0;
// The slug is the path segment after `/learn/`, minus a trailing slash.
const slug = matched
.slice(matched.indexOf("/learn/") + "/learn/".length)
.replace(/\/$/, "")
.toLowerCase();
if (learnUrl(slug) == null) continue; // unknown slug → leave as plain text
if (start > lastIndex) nodes.push(line.slice(lastIndex, start));
nodes.push(
<InlineLearnLink key={`learn-${key++}`} slug={slug}>
{matched}
</InlineLearnLink>,
);
lastIndex = start + matched.length;
}
if (nodes.length === 0) return line;
if (lastIndex < line.length) nodes.push(line.slice(lastIndex));
return <>{nodes}</>;
}
/**
* Render one paragraph's inner text: each single `\n` becomes a `<br/>`, each
* line is (optionally) chart-token-stripped, then run through the
* catalog-whitelisted Learn linkifier. Stripping per LINE preserves the
* `\n` breaks that a whole-string strip would collapse. Exported so the
* Coach streaming renderer can settle completed paragraphs with the same
* markup the non-streaming path produces.
*/
export function ParagraphText({
text,
strip = false,
linkify = true,
}: {
text: string;
strip?: boolean;
linkify?: boolean;
}) {
const lines = text.split("\n");
return (
<>
{lines.map((line, i) => {
const cleaned = strip ? stripChartTokens(line) : line;
return (
<Fragment key={i}>
{i > 0 && <br />}
{linkify ? linkifyLearnLinks(cleaned) : cleaned}
</Fragment>
);
})}
</>
);
}
export interface ProseBlocksProps {
/** Trusted-as-text prose (model output already verified server-side). */
text: string;
/**
* Strip stray `metric:<TYPE>` chart tokens first (defence-in-depth).
* Defaults true for AI prose; pass false for already-cleaned input or
* for plain user text where a literal `metric:` substring is meaningful.
*/
strip?: boolean;
/** Linkify catalog-known `/learn/<slug>` references. Default true. */
linkify?: boolean;
/** Per-paragraph class override (defaults to `PROSE_PARAGRAPH_CLASS`). */
paragraphClassName?: string;
}
export function ProseBlocks({
text,
strip = true,
linkify = true,
paragraphClassName,
}: ProseBlocksProps) {
// Split on blank lines FIRST (raw text), then strip per line inside
// `ParagraphText` — a whole-string strip would collapse the breaks.
// Drop a paragraph that is empty once its tokens are stripped (a rare
// token-only line), so no hollow `<p>` renders.
const paras = splitParagraphs(text).filter(
(p) => !strip || stripChartTokens(p).length > 0,
);
return (
<>
{paras.map((p, i) => (
<p key={i} className={cn(paragraphClassName ?? PROSE_PARAGRAPH_CLASS)}>
<ParagraphText text={p} strip={strip} linkify={linkify} />
</p>
))}
</>
);
}
+49 -1
View File
@@ -1,8 +1,9 @@
"use client";
import type { ReactNode } from "react";
import { ExternalLink } from "lucide-react";
import { learnLinkForMetric } from "@/lib/learn-links";
import { learnLinkForMetric, learnUrl } from "@/lib/learn-links";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -55,3 +56,50 @@ export function LearnMoreLink({ concept, className }: LearnMoreLinkProps) {
</a>
);
}
/**
* v1.22 (W5) — inline, catalog-whitelisted Learn anchor for AI prose.
*
* Where `LearnMoreLink` resolves a *concept* to its guide for a deterministic
* UI surface, this variant linkifies a `/learn/<slug>` reference that already
* appears in trusted prose (the Coach reply, post-filtered server-side by
* `scrubUnknownLearnLinks`). The slug is resolved through `learnUrl` — the same
* closed-set, registry-backed builder — so an unknown slug renders the children
* as plain text (fail-closed): a drifted post-filter can never produce a
* clickable invented href, and there is no markdown / HTML parsing involved.
*
* The visible label is the caller's `children` (the original URL text), keeping
* the reference verbatim; the safe attributes (`target="_blank"`,
* `rel="noopener noreferrer"`) mirror `LearnMoreLink`.
*/
export interface InlineLearnLinkProps {
/** The `/learn/<slug>` path segment to resolve and link. */
slug: string;
/** Visible anchor text (typically the matched URL substring). */
children: ReactNode;
className?: string;
}
export function InlineLearnLink({
slug,
children,
className,
}: InlineLearnLinkProps) {
const url = learnUrl(slug);
if (url == null) return <>{children}</>;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
data-slot="inline-learn-link"
className={cn(
"text-primary underline decoration-from-font underline-offset-2 hover:no-underline",
className,
)}
>
{children}
</a>
);
}