fix(chat): nested lists, and numbered lists split by a blank line keep counting

MarkdownContent nests list items by indent and starts an ordered list at the
number its text gives, so a list broken by a blank line or a stray block no
longer restarts at 1.
This commit is contained in:
2026-11-06 15:06:09 +07:00
parent d086a7f198
commit c557c0ad2f
2 changed files with 70 additions and 9 deletions
@@ -2,7 +2,9 @@
* @file MarkdownContent.tsx
* @description Lightweight markdown renderer for conversation messages. Supports the
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
* fenced code blocks, ATX headings, ordered/unordered lists (nested by indent; a
* numbered list split by a blank line keeps counting from its own numbers),
* task lists, blockquotes,
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
@@ -68,7 +70,7 @@ import { onLinkClick } from "../../lib/openInPreview";
type Block =
| { kind: "code"; lang: string; code: string }
| { kind: "heading"; level: number; text: string }
| { kind: "list"; ordered: boolean; items: string[] }
| { kind: "list"; ordered: boolean; start: number; items: string[] }
| { kind: "quote"; text: string }
| { kind: "hr" }
| {
@@ -196,27 +198,35 @@ function parseBlocks(src: string): Block[] {
if (ulMatch || olMatch) {
const ordered = !!olMatch;
const itemRe = ordered ? OL_RE : UL_RE;
const base = (olMatch ?? ulMatch)![1]!.length;
// The number the list starts at: a list broken by a blank line or a
// stray block still counts on from where the text says ("2.", "3.").
const start = olMatch ? Number(olMatch[2]) || 1 : 1;
const items: string[] = [];
while (i < lines.length) {
const m = lines[i]!.match(itemRe);
if (m) {
if (m && m[1]!.length <= base) {
items.push(m[3]!);
i++;
// The item's own lines: indented text, and a list nested under it
// (indented deeper than this list's marker) — kept with its
// relative indent so the item renders it as a sub-list.
while (
i < lines.length &&
lines[i]!.trim() !== "" &&
!lines[i]!.match(UL_RE) &&
!lines[i]!.match(OL_RE) &&
/^\s+\S/.test(lines[i]!)
/^\s+\S/.test(lines[i]!) &&
(lines[i]!.match(/^\s*/)![0].length > base ||
(!lines[i]!.match(UL_RE) && !lines[i]!.match(OL_RE)))
) {
items[items.length - 1] += "\n" + lines[i]!.trim();
const nested = UL_RE.test(lines[i]!) || OL_RE.test(lines[i]!);
items[items.length - 1] += "\n" + (nested ? lines[i]! : lines[i]!.trim());
i++;
}
} else {
break;
}
}
blocks.push({ kind: "list", ordered, items });
blocks.push({ kind: "list", ordered, start, items });
continue;
}
@@ -524,8 +534,27 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
return out;
}
/** Render a single list item, handling [ ] / [x] task list prefixes. */
/** Render a single list item: its text, then any list nested under it. */
function renderListItem(item: string, key: string): React.ReactNode {
const lines = item.split("\n");
const sub = lines.findIndex((l, n) => n > 0 && (UL_RE.test(l) || OL_RE.test(l)));
if (sub > 0) {
const rest = lines.slice(sub);
const indent = Math.min(...rest.filter((l) => l.trim()).map((l) => l.match(/^\s*/)![0].length));
return (
<>
{renderItemText(lines.slice(0, sub).join("\n"), key)}
<div className="mt-1">
<MarkdownContent text={rest.map((l) => l.slice(indent)).join("\n")} dense />
</div>
</>
);
}
return renderItemText(item, key);
}
/** An item's own text, handling [ ] / [x] task list prefixes. */
function renderItemText(item: string, key: string): React.ReactNode {
const taskMatch = item.match(/^\[([ xX])\]\s+(.*)$/s);
if (taskMatch) {
const checked = taskMatch[1]!.toLowerCase() === "x";
@@ -594,6 +623,7 @@ export function MarkdownContent({ text, dense = false, prose = false }: Markdown
return (
<ol
key={idx}
start={b.start !== 1 ? b.start : undefined}
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
>
{b.items.map((item, i) => (
@@ -89,6 +89,37 @@ describe("<MarkdownContent />", () => {
expect(container.querySelectorAll("ol li")).toHaveLength(2);
});
it("keeps one numbered list when its items carry indented bullets", () => {
const text = [
"Có 4 điểm:",
"",
"1. **yt-dlp**",
" - Hướng dẫn",
" - YouTube",
"2. **Nút Setup**",
" - site-packages",
"3. **Log khởi động**",
" Có thể bỏ dòng này.",
"4. **Nên kiểm tra**",
].join("\n");
const { container } = render(<MarkdownContent text={text} />);
const top = container.querySelectorAll(":scope > div > ol");
expect(top).toHaveLength(1);
expect(top[0]!.querySelectorAll(":scope > li")).toHaveLength(4);
expect(top[0]!.querySelectorAll("ul li")).toHaveLength(3);
expect(screen.getByText(/Có thể bỏ dòng này/)).toBeInTheDocument();
});
it("continues the numbering of a list split by a blank line or a block", () => {
const { container } = render(
<MarkdownContent text={"1. first\n\n```\ncode\n```\n\n2. second\n3. third"} />
);
const lists = container.querySelectorAll("ol");
expect(lists).toHaveLength(2);
expect(lists[0]!.getAttribute("start")).toBeNull();
expect(lists[1]!.getAttribute("start")).toBe("2");
});
it("renders blockquotes", () => {
const { container } = render(<MarkdownContent text={"> a quote"} />);
expect(container.querySelector("blockquote")).not.toBeNull();