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:
@@ -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
@@ -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();
|
||||
|
||||
+20
-21
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user