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();
|
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 PIN_THRESHOLD_PX: number = 80;
|
||||||
const FAB_THRESHOLD_PX: number = 400;
|
const FAB_THRESHOLD_PX: number = 400;
|
||||||
const FENCE: string = "```";
|
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 ENTER_KEY: string = "Enter";
|
||||||
const ESCAPE_KEY: string = "Escape";
|
const ESCAPE_KEY: string = "Escape";
|
||||||
const INLINE_RE: RegExp =
|
const INLINE_RE: RegExp =
|
||||||
@@ -79,11 +84,13 @@ function inlineNodes(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ```fences``` → <pre class="md-code">, `code`, **bold**, *italic*,
|
/** Block markdown: ```fences```, # headings, --- rules, > quotes, -/*
|
||||||
* [t](http…) links (http/https only). Plain segments keep the bubble's
|
* lists, 1. lists; inline: `code`, **bold**, *italic*, [t](http…)
|
||||||
* pre-wrap. Unterminated fences (streaming) render the tail as code. */
|
* 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[] {
|
export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
|
||||||
const out: React.ReactNode[] = [];
|
const out: React.ReactNode[] = [];
|
||||||
|
const lines: string[] = text.split("\n");
|
||||||
let plain: string[] = [];
|
let plain: string[] = [];
|
||||||
let code: string[] | null = null;
|
let code: string[] | null = null;
|
||||||
let n = 0;
|
let n = 0;
|
||||||
@@ -94,7 +101,29 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
|
|||||||
plain = [];
|
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 (line.trimStart().startsWith(FENCE)) {
|
||||||
if (code === null) {
|
if (code === null) {
|
||||||
flushPlain();
|
flushPlain();
|
||||||
@@ -108,11 +137,77 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
|
|||||||
n += 1;
|
n += 1;
|
||||||
code = null;
|
code = null;
|
||||||
}
|
}
|
||||||
} else if (code === null) {
|
i += 1;
|
||||||
plain.push(line);
|
continue;
|
||||||
} else {
|
|
||||||
code.push(line);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
if (code === null) {
|
||||||
flushPlain();
|
flushPlain();
|
||||||
|
|||||||
+21
-22
@@ -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 { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Repo, RepoImage, SessionListItem } from "./protocol";
|
import type { Repo, RepoImage, SessionListItem } from "./protocol";
|
||||||
@@ -106,8 +112,7 @@ describe("SpawnView connect flow", () => {
|
|||||||
connected = true;
|
connected = true;
|
||||||
return { username: "alice" };
|
return { username: "alice" };
|
||||||
}
|
}
|
||||||
if (url.endsWith("/api/gitlab/repos"))
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/one"), repo("g/two")];
|
||||||
return [repo("g/one"), repo("g/two")];
|
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
render(tree(makeStore()));
|
render(tree(makeStore()));
|
||||||
@@ -181,9 +186,7 @@ describe("SpawnView repo picker", () => {
|
|||||||
expect(screen.queryByText("g/alpha")).toBeNull();
|
expect(screen.queryByText("g/alpha")).toBeNull();
|
||||||
expect(screen.getByText("g/beta")).toBeInTheDocument();
|
expect(screen.getByText("g/beta")).toBeInTheDocument();
|
||||||
|
|
||||||
const item = screen
|
const item = screen.getByText("g/beta").closest(".repo-item") as HTMLElement;
|
||||||
.getByText("g/beta")
|
|
||||||
.closest(".repo-item") as HTMLElement;
|
|
||||||
fireEvent.keyDown(item, { key: "Enter" });
|
fireEvent.keyDown(item, { key: "Enter" });
|
||||||
expect(screen.getByLabelText("Branch")).toHaveValue("dev");
|
expect(screen.getByLabelText("Branch")).toHaveValue("dev");
|
||||||
|
|
||||||
@@ -195,9 +198,7 @@ describe("SpawnView repo picker", () => {
|
|||||||
connectedMock();
|
connectedMock();
|
||||||
render(tree(makeStore()));
|
render(tree(makeStore()));
|
||||||
await flush();
|
await flush();
|
||||||
const item = screen
|
const item = screen.getByText("g/alpha").closest(".repo-item") as HTMLElement;
|
||||||
.getByText("g/alpha")
|
|
||||||
.closest(".repo-item") as HTMLElement;
|
|
||||||
|
|
||||||
const enter = fireEvent.keyDown(item, { key: "Enter" });
|
const enter = fireEvent.keyDown(item, { key: "Enter" });
|
||||||
expect(enter).toBe(false); // preventDefault consumed: no page scroll
|
expect(enter).toBe(false); // preventDefault consumed: no page scroll
|
||||||
@@ -269,7 +270,7 @@ describe("SpawnView spawn+poll", () => {
|
|||||||
repo: "g/proj",
|
repo: "g/proj",
|
||||||
startedAt: 1,
|
startedAt: 1,
|
||||||
online: true,
|
online: true,
|
||||||
busy: false,
|
busy: false,
|
||||||
lastEventAt: 1,
|
lastEventAt: 1,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -357,8 +358,7 @@ describe("SpawnView spawn+poll", () => {
|
|||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
const failingRefresh = makeStore({
|
const failingRefresh = makeStore({
|
||||||
refresh: (): Promise<SessionListItem[]> =>
|
refresh: (): Promise<SessionListItem[]> => Promise.reject(new Error("boom")),
|
||||||
Promise.reject(new Error("boom")),
|
|
||||||
});
|
});
|
||||||
render(tree(failingRefresh));
|
render(tree(failingRefresh));
|
||||||
await flush();
|
await flush();
|
||||||
@@ -536,10 +536,7 @@ describe("SpawnView repo images (registry)", () => {
|
|||||||
|
|
||||||
const ok = screen.getByText("custom image");
|
const ok = screen.getByText("custom image");
|
||||||
expect(ok.className).toContain("ok");
|
expect(ok.className).toContain("ok");
|
||||||
expect(ok).toHaveAttribute(
|
expect(ok).toHaveAttribute("title", "custom image lvmh-worker-alpha — built");
|
||||||
"title",
|
|
||||||
"custom image lvmh-worker-alpha — built",
|
|
||||||
);
|
|
||||||
|
|
||||||
const warn = screen.getByText("needs build");
|
const warn = screen.getByText("needs build");
|
||||||
expect(warn.className).toContain("warn");
|
expect(warn.className).toContain("warn");
|
||||||
@@ -565,8 +562,7 @@ describe("SpawnView repo images (registry)", () => {
|
|||||||
|
|
||||||
const prepareCalls = fetchMock.mock.calls.filter(
|
const prepareCalls = fetchMock.mock.calls.filter(
|
||||||
([u, i]) =>
|
([u, i]) =>
|
||||||
String(u).endsWith("/api/repos/g/alpha/prepare") &&
|
String(u).endsWith("/api/repos/g/alpha/prepare") && i?.method === "POST",
|
||||||
i?.method === "POST",
|
|
||||||
);
|
);
|
||||||
expect(prepareCalls).toHaveLength(1);
|
expect(prepareCalls).toHaveLength(1);
|
||||||
expect(PUSH_TOAST).toHaveBeenCalledWith(
|
expect(PUSH_TOAST).toHaveBeenCalledWith(
|
||||||
@@ -618,8 +614,7 @@ describe("SpawnView repo images (registry)", () => {
|
|||||||
if (url.endsWith("/api/gitlab/status"))
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
return { connected: true, baseUrl: "https://gl", username: "a" };
|
return { connected: true, baseUrl: "https://gl", username: "a" };
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha")];
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha")];
|
||||||
if (url.endsWith("/api/repos"))
|
if (url.endsWith("/api/repos")) return jsonResponse({ error: "boom" }, 500);
|
||||||
return jsonResponse({ error: "boom" }, 500);
|
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
render(tree(makeStore()));
|
render(tree(makeStore()));
|
||||||
@@ -691,7 +686,9 @@ describe("spawn model selection", () => {
|
|||||||
await flush();
|
await flush();
|
||||||
const sel = screen.getByLabelText("Initial model");
|
const sel = screen.getByLabelText("Initial model");
|
||||||
await waitFor(() => expect(sel.querySelectorAll("optgroup").length).toBe(2));
|
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 () => {
|
it("selected model is sent in the spawn body", async () => {
|
||||||
@@ -712,7 +709,9 @@ describe("spawn model selection", () => {
|
|||||||
await flush();
|
await flush();
|
||||||
fireEvent.click(screen.getByText("g/p"));
|
fireEvent.click(screen.getByText("g/p"));
|
||||||
const sel = screen.getByLabelText("Initial model");
|
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.change(sel, { target: { value: "zai-renaud/glm-5.3" } });
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
await flush();
|
await flush();
|
||||||
|
|||||||
@@ -777,6 +777,48 @@ details.thinking .thinking-body {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
color: var(--text-dim);
|
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 {
|
.bubble code {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 0.92em;
|
font-size: 0.92em;
|
||||||
|
|||||||
Reference in New Issue
Block a user