feat: markdown block parsing in LLM output — headings, lists (ul/ol), blockquotes, hr on top of fences + inline; 300 tests green

This commit is contained in:
2026-09-02 09:49:56 +00:00
parent 0f245c9ee2
commit 09fc72556d
4 changed files with 226 additions and 30 deletions
+60
View File
@@ -1041,3 +1041,63 @@ describe("unified tool card", () => {
expect(screen.queryByText("next msg")).toBeNull();
});
});
describe("renderMarkdown blocks", () => {
it("headings render as h1-h6 with inline content", () => {
const out = renderMarkdown("## Hello **world**");
const h = out[0] as React.ReactElement<{ className: string }>;
expect(h.type).toBe("h2");
expect(h.props.className).toContain("md-h-2");
});
it("--- renders a horizontal rule", () => {
const out = renderMarkdown("above\n---\nbelow");
expect(out.some((n) => (n as React.ReactElement).type === "hr")).toBe(true);
});
it("> lines group into one blockquote", () => {
const out = renderMarkdown("> quoted a\n> quoted b\nplain");
const q = out.find(
(n) => (n as React.ReactElement).type === "blockquote",
) as React.ReactElement<{ children: React.ReactNode[] }>;
expect(q).toBeDefined();
expect(q.props.children.join("")).toContain("quoted a");
expect(q.props.children.join("")).toContain("quoted b");
});
it("- and * lines render as an unordered list", () => {
const out = renderMarkdown("- one\n- two\n* three");
const ul = out[0] as React.ReactElement<{ children: React.ReactNode[] }>;
expect(ul.type).toBe("ul");
expect(ul.props.children).toHaveLength(3);
});
it("1. lines render as an ordered list", () => {
const out = renderMarkdown("1. first\n2. second");
const ol = out[0] as React.ReactElement<{ children: React.ReactNode[] }>;
expect(ol.type).toBe("ol");
expect(ol.props.children).toHaveLength(2);
});
it("emphasis start is not a list marker", () => {
const out = renderMarkdown("*bold start* to line");
expect(out.some((n) => (n as React.ReactElement).type === "ul")).toBe(false);
});
it("list items keep inline markdown", () => {
const out = renderMarkdown("- has `code` and [l](https://x.io)");
const ul = out[0] as React.ReactElement<{ children: React.ReactNode[] }>;
const li = ul.props.children[0] as React.ReactElement<{
children: React.ReactNode[];
}>;
expect(JSON.stringify(li.props.children)).toContain("code");
expect(JSON.stringify(li.props.children)).toContain("https://x.io");
});
it("fences still win over block markers", () => {
const out = renderMarkdown("```\n- not a list\n# not a heading\n```");
const pre = out[0] as React.ReactElement<{ children: string }>;
expect(pre.type).toBe("pre");
expect(pre.props.children).toContain("- not a list");
});
});
+103 -8
View File
@@ -6,6 +6,11 @@ const COPY_FEEDBACK_MS: number = 1200;
const PIN_THRESHOLD_PX: number = 80;
const FAB_THRESHOLD_PX: number = 400;
const FENCE: string = "```";
const HEADING_RE: RegExp = /^(#{1,6})\s+(.*)$/;
const HR_RE: RegExp = /^(?:-{3,}|\*{3,}|_{3,})$/;
const QUOTE_RE: RegExp = /^>\s?(.*)$/;
const UL_RE: RegExp = /^[-*+]\s+(.*)$/;
const OL_RE: RegExp = /^\d{1,9}[.)]\s+(.*)$/;
const ENTER_KEY: string = "Enter";
const ESCAPE_KEY: string = "Escape";
const INLINE_RE: RegExp =
@@ -79,11 +84,13 @@ function inlineNodes(
return out;
}
/** ```fences``` → <pre class="md-code">, `code`, **bold**, *italic*,
* [t](http…) links (http/https only). Plain segments keep the bubble's
* pre-wrap. Unterminated fences (streaming) render the tail as code. */
/** Block markdown: ```fences```, # headings, --- rules, > quotes, -/*
* lists, 1. lists; inline: `code`, **bold**, *italic*, [t](http…)
* links (http/https only). Plain segments keep the bubble's pre-wrap.
* Unterminated fences (streaming) render the tail as code. */
export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
const out: React.ReactNode[] = [];
const lines: string[] = text.split("\n");
let plain: string[] = [];
let code: string[] | null = null;
let n = 0;
@@ -94,7 +101,29 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
plain = [];
}
};
for (const line of text.split("\n")) {
// consecutive lines sharing a marker accumulate into one block element
const collect = (
re: RegExp,
from: number,
): { items: string[]; next: number } => {
const items: string[] = [];
let k: number = from;
while (k < lines.length) {
const raw: string | undefined = lines[k];
if (raw === undefined) break;
const m: RegExpMatchArray | null = raw.match(re);
if (m === null) break;
const item: string | undefined = m[1];
if (item === undefined) break;
items.push(item);
k += 1;
}
return { items, next: k };
};
let i: number = 0;
while (i < lines.length) {
const line: string | undefined = lines[i];
if (line === undefined) break;
if (line.trimStart().startsWith(FENCE)) {
if (code === null) {
flushPlain();
@@ -108,11 +137,77 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
n += 1;
code = null;
}
} else if (code === null) {
plain.push(line);
} else {
code.push(line);
i += 1;
continue;
}
if (code !== null) {
code.push(line);
i += 1;
continue;
}
const heading: RegExpMatchArray | null = line.match(HEADING_RE);
if (heading !== null) {
flushPlain();
const level: number = Math.min(heading[1]?.length ?? 1, 6);
const Tag: keyof React.JSX.IntrinsicElements = `h${level}` as "h1";
out.push(
<Tag className={`md-h md-h-${level}`} key={`h-${n}`}>
{inlineNodes(heading[2] ?? "", query, `h-${n}`)}
</Tag>,
);
n += 1;
i += 1;
continue;
}
if (HR_RE.test(line.trim())) {
flushPlain();
out.push(<hr className="md-hr" key={`r-${n}`} />);
n += 1;
i += 1;
continue;
}
if (QUOTE_RE.test(line)) {
flushPlain();
const q: { items: string[]; next: number } = collect(QUOTE_RE, i);
out.push(
<blockquote className="md-quote" key={`q-${n}`}>
{inlineNodes(q.items.join("\n"), query, `q-${n}`)}
</blockquote>,
);
n += 1;
i = q.next;
continue;
}
if (UL_RE.test(line)) {
flushPlain();
const l: { items: string[]; next: number } = collect(UL_RE, i);
out.push(
<ul className="md-list" key={`u-${n}`}>
{l.items.map((item, k) => (
<li key={k}>{inlineNodes(item, query, `u-${n}-${k}`)}</li>
))}
</ul>,
);
n += 1;
i = l.next;
continue;
}
if (OL_RE.test(line)) {
flushPlain();
const l: { items: string[]; next: number } = collect(OL_RE, i);
out.push(
<ol className="md-list md-list-ol" key={`o-${n}`}>
{l.items.map((item, k) => (
<li key={k}>{inlineNodes(item, query, `o-${n}-${k}`)}</li>
))}
</ol>,
);
n += 1;
i = l.next;
continue;
}
plain.push(line);
i += 1;
}
if (code === null) {
flushPlain();
+21 -22
View File
@@ -1,4 +1,10 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Repo, RepoImage, SessionListItem } from "./protocol";
@@ -106,8 +112,7 @@ describe("SpawnView connect flow", () => {
connected = true;
return { username: "alice" };
}
if (url.endsWith("/api/gitlab/repos"))
return [repo("g/one"), repo("g/two")];
if (url.endsWith("/api/gitlab/repos")) return [repo("g/one"), repo("g/two")];
return [];
});
render(tree(makeStore()));
@@ -181,9 +186,7 @@ describe("SpawnView repo picker", () => {
expect(screen.queryByText("g/alpha")).toBeNull();
expect(screen.getByText("g/beta")).toBeInTheDocument();
const item = screen
.getByText("g/beta")
.closest(".repo-item") as HTMLElement;
const item = screen.getByText("g/beta").closest(".repo-item") as HTMLElement;
fireEvent.keyDown(item, { key: "Enter" });
expect(screen.getByLabelText("Branch")).toHaveValue("dev");
@@ -195,9 +198,7 @@ describe("SpawnView repo picker", () => {
connectedMock();
render(tree(makeStore()));
await flush();
const item = screen
.getByText("g/alpha")
.closest(".repo-item") as HTMLElement;
const item = screen.getByText("g/alpha").closest(".repo-item") as HTMLElement;
const enter = fireEvent.keyDown(item, { key: "Enter" });
expect(enter).toBe(false); // preventDefault consumed: no page scroll
@@ -269,7 +270,7 @@ describe("SpawnView spawn+poll", () => {
repo: "g/proj",
startedAt: 1,
online: true,
busy: false,
busy: false,
lastEventAt: 1,
},
]
@@ -357,8 +358,7 @@ describe("SpawnView spawn+poll", () => {
return [];
});
const failingRefresh = makeStore({
refresh: (): Promise<SessionListItem[]> =>
Promise.reject(new Error("boom")),
refresh: (): Promise<SessionListItem[]> => Promise.reject(new Error("boom")),
});
render(tree(failingRefresh));
await flush();
@@ -536,10 +536,7 @@ describe("SpawnView repo images (registry)", () => {
const ok = screen.getByText("custom image");
expect(ok.className).toContain("ok");
expect(ok).toHaveAttribute(
"title",
"custom image lvmh-worker-alpha — built",
);
expect(ok).toHaveAttribute("title", "custom image lvmh-worker-alpha — built");
const warn = screen.getByText("needs build");
expect(warn.className).toContain("warn");
@@ -565,8 +562,7 @@ describe("SpawnView repo images (registry)", () => {
const prepareCalls = fetchMock.mock.calls.filter(
([u, i]) =>
String(u).endsWith("/api/repos/g/alpha/prepare") &&
i?.method === "POST",
String(u).endsWith("/api/repos/g/alpha/prepare") && i?.method === "POST",
);
expect(prepareCalls).toHaveLength(1);
expect(PUSH_TOAST).toHaveBeenCalledWith(
@@ -618,8 +614,7 @@ describe("SpawnView repo images (registry)", () => {
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha")];
if (url.endsWith("/api/repos"))
return jsonResponse({ error: "boom" }, 500);
if (url.endsWith("/api/repos")) return jsonResponse({ error: "boom" }, 500);
return [];
});
render(tree(makeStore()));
@@ -691,7 +686,9 @@ describe("spawn model selection", () => {
await flush();
const sel = screen.getByLabelText("Initial model");
await waitFor(() => expect(sel.querySelectorAll("optgroup").length).toBe(2));
expect(sel.querySelector('option[value="zai-renaud/glm-5.3"]')).not.toBeNull();
expect(
sel.querySelector('option[value="zai-renaud/glm-5.3"]'),
).not.toBeNull();
});
it("selected model is sent in the spawn body", async () => {
@@ -712,7 +709,9 @@ describe("spawn model selection", () => {
await flush();
fireEvent.click(screen.getByText("g/p"));
const sel = screen.getByLabelText("Initial model");
await waitFor(() => expect(sel.querySelectorAll("option").length).toBeGreaterThan(1));
await waitFor(() =>
expect(sel.querySelectorAll("option").length).toBeGreaterThan(1),
);
fireEvent.change(sel, { target: { value: "zai-renaud/glm-5.3" } });
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
+42
View File
@@ -777,6 +777,48 @@ details.thinking .thinking-body {
overflow-x: auto;
color: var(--text-dim);
}
.bubble .md-h {
margin: 14px 0 6px;
line-height: 1.3;
color: var(--text);
}
.bubble .md-h:first-child {
margin-top: 4px;
}
.bubble .md-h-1 {
font-size: 1.35em;
}
.bubble .md-h-2 {
font-size: 1.22em;
}
.bubble .md-h-3 {
font-size: 1.1em;
}
.bubble .md-h-4,
.bubble .md-h-5,
.bubble .md-h-6 {
font-size: 1em;
}
.bubble .md-hr {
border: 0;
border-top: 1px solid var(--border);
margin: 12px 0;
}
.bubble .md-quote {
margin: 8px 0;
padding: 2px 0 2px 12px;
border-left: 3px solid var(--border-strong);
color: var(--text-dim);
white-space: pre-wrap;
}
.bubble .md-list {
margin: 8px 0;
padding-left: 22px;
white-space: pre-wrap;
}
.bubble .md-list li {
margin: 3px 0;
}
.bubble code {
font-family: var(--mono);
font-size: 0.92em;