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)} +