From c557c0ad2fcf341c4a8009dbb71b6a3f0ce65f9e Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 6 Nov 2026 15:06:09 +0700 Subject: [PATCH] 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. --- .../conversation/MarkdownContent.tsx | 48 +++++++++++++++---- .../__tests__/MarkdownContent.test.tsx | 31 ++++++++++++ 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/client/src/components/conversation/MarkdownContent.tsx b/client/src/components/conversation/MarkdownContent.tsx index 9b1e61a5..65b8b837 100644 --- a/client/src/components/conversation/MarkdownContent.tsx +++ b/client/src/components/conversation/MarkdownContent.tsx @@ -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)} +
+ l.slice(indent)).join("\n")} dense /> +
+ + ); + } + 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 (
    {b.items.map((item, i) => ( diff --git a/client/src/components/conversation/__tests__/MarkdownContent.test.tsx b/client/src/components/conversation/__tests__/MarkdownContent.test.tsx index 5f2f435a..5076d4d4 100644 --- a/client/src/components/conversation/__tests__/MarkdownContent.test.tsx +++ b/client/src/components/conversation/__tests__/MarkdownContent.test.tsx @@ -89,6 +89,37 @@ describe("", () => { 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(); + 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( + + ); + 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( a quote"} />); expect(container.querySelector("blockquote")).not.toBeNull();