blocks.
+ expect((html.match(/ and leaves unclosed markers literal", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain("key takeaway");
+ expect(html).toContain("A stray ** stays literal.");
+ });
});
describe("StreamedProse", () => {
diff --git a/src/components/insights/coach-panel/streamed-prose.tsx b/src/components/insights/coach-panel/streamed-prose.tsx
index 7b41f4df0..2ea1be8d6 100644
--- a/src/components/insights/coach-panel/streamed-prose.tsx
+++ b/src/components/insights/coach-panel/streamed-prose.tsx
@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
ProseBlocks,
- ParagraphText,
+ ProseBlock,
splitParagraphs,
PROSE_PARAGRAPH_CLASS,
} from "@/components/insights/prose-blocks";
@@ -96,13 +96,11 @@ export function StreamedProse({ content, streaming }: StreamedProseProps) {
return (
<>
- {/* Completed paragraphs settle immediately — plain text + linkifier,
- identical to the non-streaming render so nothing reflows when the
- turn finishes. */}
+ {/* Completed paragraphs settle immediately — plain text + linkifier
+ (and real list markup for `- ` runs), identical to the
+ non-streaming render so nothing reflows when the turn finishes. */}
{settled.map((p, i) => (
-
-
-
+
))}
{/* The growing tail paragraph: word-by-word fade. No linkify here —
a half-arrived URL must not flicker into a link mid-stream; it
diff --git a/src/components/insights/prose-blocks.tsx b/src/components/insights/prose-blocks.tsx
index 18835477d..b16d1aba2 100644
--- a/src/components/insights/prose-blocks.tsx
+++ b/src/components/insights/prose-blocks.tsx
@@ -102,13 +102,55 @@ function linkifyLearnLinks(line: string): ReactNode {
return <>{nodes}>;
}
+/**
+ * `**bold**` span matcher. The ONLY inline emphasis the formatting contract
+ * allows the models; anything else (headings, underscores, backticks) stays
+ * literal text. No `*` or `\n` inside the span, so an unclosed `**` or a
+ * stray asterisk falls through verbatim — fail-closed, never swallowed.
+ */
+const BOLD_SPAN_RE = /\*\*([^*\n]+)\*\*/g;
+
+/**
+ * Render one line's inline content: closed-set `**bold**` spans become
+ * ``, and each plain/bold fragment runs through the catalog-
+ * whitelisted Learn linkifier. Pure `matchAll` + slicing over the trusted
+ * string — no parser, no markup passthrough.
+ */
+function renderInline(line: string, linkify: boolean): ReactNode {
+ const linkified = (s: string) => (linkify ? linkifyLearnLinks(s) : s);
+ if (!line.includes("**")) return linkified(line);
+
+ const nodes: ReactNode[] = [];
+ let lastIndex = 0;
+ let key = 0;
+ for (const match of line.matchAll(BOLD_SPAN_RE)) {
+ const start = match.index ?? 0;
+ if (start > lastIndex)
+ nodes.push(
+
+ {linkified(line.slice(lastIndex, start))}
+ ,
+ );
+ nodes.push({linkified(match[1])});
+ lastIndex = start + match[0].length;
+ }
+ if (nodes.length === 0) return linkified(line);
+ if (lastIndex < line.length)
+ nodes.push(
+
+ {linkified(line.slice(lastIndex))}
+ ,
+ );
+ return <>{nodes}>;
+}
+
/**
* Render one paragraph's inner text: each single `\n` becomes a `
`, 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.
+ * line is (optionally) chart-token-stripped, then run through the closed-set
+ * inline renderer (`**bold**` + 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,
@@ -127,7 +169,7 @@ export function ParagraphText({
return (
{i > 0 &&
}
- {linkify ? linkifyLearnLinks(cleaned) : cleaned}
+ {renderInline(cleaned, linkify)}
);
})}
@@ -135,6 +177,66 @@ export function ParagraphText({
);
}
+/** `- ` / `– ` / `• ` list-item marker (leading whitespace tolerated). */
+const BULLET_LINE_RE = /^\s*[-–•]\s+/;
+
+/**
+ * Render ONE blank-line-delimited block: consecutive `- ` lines group into a
+ * real `` (marker stripped, one `- ` per line), the remaining line runs
+ * render as `
` with `
` joins — so a model reply that enumerates
+ * ("- option one\n- option two") reads as a list instead of dash-prefixed
+ * text lines. Same pure line-splitting as everything else here: no parser,
+ * no markup passthrough. Exported for the Coach streaming renderer.
+ */
+export function ProseBlock({
+ text,
+ strip = false,
+ linkify = true,
+ paragraphClassName,
+}: {
+ text: string;
+ strip?: boolean;
+ linkify?: boolean;
+ paragraphClassName?: string;
+}) {
+ const blockClass = cn(paragraphClassName ?? PROSE_PARAGRAPH_CLASS);
+ const lines = text.split("\n");
+ const runs: Array<{ bullets: boolean; lines: string[] }> = [];
+ for (const line of lines) {
+ const bullets = BULLET_LINE_RE.test(line);
+ const last = runs[runs.length - 1];
+ if (last && last.bullets === bullets) last.lines.push(line);
+ else runs.push({ bullets, lines: [line] });
+ }
+ return (
+ <>
+ {runs.map((run, i) =>
+ run.bullets ? (
+
+ {run.lines.map((line, j) => (
+ -
+
+
+ ))}
+
+ ) : (
+
+
+
+ ),
+ )}
+ >
+ );
+}
+
export interface ProseBlocksProps {
/** Trusted-as-text prose (model output already verified server-side). */
text: string;
@@ -166,9 +268,13 @@ export function ProseBlocks({
return (
<>
{paras.map((p, i) => (
-
-
-
+
))}
>
);