From 1ae9fdef2d507b31a8002d2aac62ea3efbb74860 Mon Sep 17 00:00:00 2001
From: BrunooMoniz
Date: Sat, 18 Jul 2026 01:37:33 +0000
Subject: [PATCH] fix(markdown): render nested lists with an indentation stack
The chat markdown renderer built lists with regex passes anchored on the
absolute start of the line (`^(\d+)\. `, `^(?:- |\* )...`) and grouped only
consecutive sentinel lines, with no notion of depth. An indented item such as
` - Sub` never matched, fell through to the paragraph pass, and rendered as
literal `- Sub` text while the parent list fragmented. The ordered-nested case
was worse: it emitted an `` inside a ``, which is invalid HTML.
Replace the flatten-and-group passes with `_buildLists`, which parses each
item's indentation and walks a per-run indentation stack: a deeper item opens a
child list inside the still-open parent `
- ` (`
- ...
`) and
a dedent closes levels until the indentation matches an open one. Indentation is
compared relatively to the parent on the stack, so inconsistent 2-/3-/4-space
and tab widths nest the same way and mixed ordered/unordered nesting produces
valid HTML. Task-checkbox items keep their `task-item` class and nest correctly;
a small flex-wrap/flex-basis rule drops a sub-list nested under a task item onto
its own line instead of sitting inline after the label.
Adds nested unordered/ordered/mixed/task/deep cases to
tests/test_markdown_rendering_js.py and a mixed-nested sample to the streaming
corpus; the existing flat-list, table, code, and autolink contracts are
unchanged.
---
static/js/markdown.js | 145 +++++++++++++++++++++---
static/style.css | 15 +++
tests/streaming/corpus.mjs | 1 +
tests/test_markdown_rendering_js.py | 169 ++++++++++++++++++++++++++++
4 files changed, 312 insertions(+), 18 deletions(-)
diff --git a/static/js/markdown.js b/static/js/markdown.js
index 8735b83e7f..88a3b6d077 100644
--- a/static/js/markdown.js
+++ b/static/js/markdown.js
@@ -482,6 +482,125 @@ export function processWithThinking(text) {
return _useSvgEmoji() ? svgifyEmoji(html) : html;
}
+// ---------------------------------------------------------------------------
+// Nested markdown lists.
+//
+// Ordered (`1.`), unordered (`- ` / `* `) and GitHub task (`- [ ] `) items are
+// grouped into properly nested / by walking the lines and driving an
+// INDENTATION STACK: an item indented past its predecessor opens a child list
+// INSIDE the open parent - (`
- ...
`), and a dedent closes
+// levels until the indentation matches an open one. Indent is compared
+// RELATIVELY to the parent on the stack (never bucketed by a fixed width), so
+// the 2-/3-/4-space and tab indentations that models emit inconsistently all
+// nest the same way, and mixed ordered/unordered nesting produces valid HTML.
+// This replaces the old flatten-and-group passes, which anchored on the
+// absolute start of the line and rendered any indented item as flat literal
+// text (and emitted an inside a for the ordered-nested case).
+// ---------------------------------------------------------------------------
+
+const _LIST_ORDERED_RE = /^([ \t]*)(\d+)\. (.*)$/;
+const _LIST_TASK_RE = /^([ \t]*)(?:- |\* )\[([ xX])\] (.*)$/;
+const _LIST_UNORDERED_RE = /^([ \t]*)(?:- |\* )(.*)$/;
+
+// Indentation width in columns: a tab counts as four columns so a tab-indented
+// child still reads as "deeper" than a space-indented parent (and vice versa).
+function _listIndentWidth(ws) {
+ return ws.replace(/\t/g, ' ').length;
+}
+
+// Parse one line into a list item, or null when it is not one. Precedence
+// mirrors the old passes: ordered, then task (before generic unordered so the
+// "- " prefix is not consumed first), then unordered. `attr` is the attribute
+// string for the emitted
- (the task-item class); `html` is its inner HTML.
+function _parseListItem(line) {
+ let m;
+ if ((m = _LIST_ORDERED_RE.exec(line))) {
+ return { indent: _listIndentWidth(m[1]), type: 'ol', html: m[3], attr: '' };
+ }
+ if ((m = _LIST_TASK_RE.exec(line))) {
+ const done = m[2].toLowerCase() === 'x';
+ const inner = `${m[3]}`;
+ return {
+ indent: _listIndentWidth(m[1]),
+ type: 'ul',
+ html: inner,
+ attr: ` class="task-item${done ? ' task-done' : ''}"`,
+ };
+ }
+ if ((m = _LIST_UNORDERED_RE.exec(line))) {
+ return { indent: _listIndentWidth(m[1]), type: 'ul', html: m[2], attr: '' };
+ }
+ return null;
+}
+
+// Build the HTML for one maximal run of consecutive list-item lines by driving
+// the indentation stack. Emitted as a single line (no internal newlines) that
+// starts with
/, so the later paragraph pass leaves the block untouched.
+function _renderListRun(items) {
+ let html = '';
+ const stack = []; // { type, indent }, outermost first
+ for (const it of items) {
+ let popped = false;
+ while (stack.length && it.indent < stack[stack.length - 1].indent) {
+ html += `
${stack.pop().type}>`;
+ popped = true;
+ }
+ const top = stack.length ? stack[stack.length - 1] : null;
+ if (top && it.indent > top.indent && !popped) {
+ // Deeper: open a child list INSIDE the still-open parent - .
+ html += `<${it.type}>`;
+ stack.push({ type: it.type, indent: it.indent });
+ html += `
- `;
+ } else if (top) {
+ // Sibling at this level: equal indent, or a dedent that overshot between
+ // two open levels. A marker-type switch closes this list and opens the
+ // other kind at the same level.
+ if (top.type === it.type) {
+ html += `
- `;
+ } else {
+ html += `
${stack.pop().type}>`;
+ html += `<${it.type}>`;
+ stack.push({ type: it.type, indent: it.indent });
+ html += `- `;
+ }
+ } else {
+ // Stack empty: open the root list. The first item sets the baseline, so a
+ // whole run that happens to be indented still renders as a top-level list.
+ html += `<${it.type}>`;
+ stack.push({ type: it.type, indent: it.indent });
+ html += `
- `;
+ }
+ html += it.html;
+ }
+ while (stack.length) html += `
${stack.pop().type}>`;
+ return html;
+}
+
+// Group the source into maximal runs of consecutive list-item lines (a blank or
+// non-list line breaks a run, matching the old "consecutive sentinels" grouping
+// so loose lists keep their current one-block-per-run rendering) and convert
+// each run to nested-list HTML.
+function _buildLists(src) {
+ const lines = src.split('\n');
+ const out = [];
+ let i = 0;
+ while (i < lines.length) {
+ if (!_parseListItem(lines[i])) {
+ out.push(lines[i]);
+ i++;
+ continue;
+ }
+ const items = [];
+ let it;
+ while (i < lines.length && (it = _parseListItem(lines[i]))) {
+ items.push(it);
+ i++;
+ }
+ out.push(_renderListRun(items));
+ }
+ return out.join('\n');
+}
+
/**
* Convert markdown to HTML
*/
@@ -721,24 +840,14 @@ export function mdToHtml(src, opts) {
.replace(/^## (.*)$/gm, '$1
')
.replace(/^# (.*)$/gm, '$1
');
- // Ordered lists (1. 2. 3. etc.)
- s = s.replace(/^(\d+)\. (.*)$/gm, '$2');
- s = s.replace(/(?:^|\n)([\s\S]*?)(?=\n(?!)|$)/g, m => `${m.trim().replace(/<\/?oli>/g, (t) => t === '' ? '- ' : '
')}
`);
-
- // GitHub-style task lists (- [ ] / - [x]) → checkbox items. Must run before
- // the generic unordered-list rule so the "- " prefix isn't consumed first.
- // Emits (with a class) so the unordered-list wrapper below treats it
- // as a list item. Used by plan mode: plan + progress render as a checklist.
- s = s.replace(/^(?:- |\* )\[([ xX])\] (.*)$/gm, (_m, mark, text) => {
- const done = mark.toLowerCase() === 'x';
- return `${text}`;
- });
-
- // Unordered lists. may carry attributes (task-item class), so the
- // wrapper preserves them when converting → - .
- s = s.replace(/^(?:- |\* )(.*)$/gm, '$1');
- s = s.replace(/(^|\n)((?:]*>[^\n]*<\/uli>(?:\n|$))+)/g, (_, prefix, block) =>
- `${prefix}
${block.trim().replace(/]*)>/g, '- ').replace(/<\/uli>/g, '
')}
`);
+ // Ordered (1.), unordered (- / *) and GitHub task (- [ ] / - [x]) lists,
+ // grouped into properly nested / by the indentation-stack builder
+ // (see _buildLists above). Task items carry the task-item class on their
+ // - ; the "- " prefix of a checkbox is matched before the generic
+ // unordered rule so it is not consumed first. Nested items render as
+ //
- ...
, including mixed ordered/unordered nesting and
+ // nested checkboxes, as valid HTML.
+ s = _buildLists(s);
// Blockquotes
s = s.replace(/^> (.*)$/gm, '$1');
diff --git a/static/style.css b/static/style.css
index 1c899e479a..2581dd72ef 100644
--- a/static/style.css
+++ b/static/style.css
@@ -2504,6 +2504,21 @@ body.bg-pattern-sparkles {
display: flex;
align-items: flex-start;
gap: 8px;
+ flex-wrap: wrap;
+ }
+ /* A sub-list nested under a task item is a third flex child; force it onto
+ its own full-width line (below the checkbox + label row) so it reads as a
+ nested list rather than sitting inline after the label. */
+ li.task-item > ul,
+ li.task-item > ol {
+ flex-basis: 100%;
+ }
+ /* The label grows from basis 0 (NOT auto/max-content) so a long label stays
+ on the checkbox's row instead of wrapping below it under flex-wrap, while a
+ nested sub-list still takes its own full-width line (rule above). */
+ li.task-item > .task-text {
+ flex: 1 1 0;
+ min-width: 0;
}
li.task-item .task-check {
flex: 0 0 auto;
diff --git a/tests/streaming/corpus.mjs b/tests/streaming/corpus.mjs
index d66768ea16..7bf7d6034f 100644
--- a/tests/streaming/corpus.mjs
+++ b/tests/streaming/corpus.mjs
@@ -13,6 +13,7 @@ export const CORPUS = [
['ordered list then text', 'Before\n\n1. first\n2. second\n3. third\n\nAfter'],
['loose list then paragraph', '- a\n\n- b\n\n- c\n\nClosing paragraph.'],
['nested list', '- top\n - nested one\n - nested two\n- back to top\n\nend'],
+ ['mixed nested list', '1. first\n - sub a\n - sub b\n2. second\n\nend'],
['blockquote', '> quoted line one\n> quoted line two\n\nplain after'],
['thematic break', 'above the line\n\n---\n\nbelow the line'],
['python code fence', 'Run this:\n\n```python\nprint("hi")\nfor i in range(3):\n print(i)\n```\n\nThat prints numbers.'],
diff --git a/tests/test_markdown_rendering_js.py b/tests/test_markdown_rendering_js.py
index 2ffe8914f7..c3b6382ffd 100644
--- a/tests/test_markdown_rendering_js.py
+++ b/tests/test_markdown_rendering_js.py
@@ -256,3 +256,172 @@ def test_dotted_python_import_paths_are_not_autolinked(node_available):
assert 'href="https://imblearn.com' not in html
assert 'href="https://sklearn.me' not in html
assert 'href="https://example.com/docs"' in html
+
+
+# --- Nested markdown lists (indentation-stack builder) ----------------------
+# The old flatten-and-group passes anchored each list regex on the ABSOLUTE
+# start of the line, so an indented item (e.g. " - Sub") never matched and fell
+# through to the paragraph pass as literal "- Sub" text, while the parent list
+# fragmented. The ordered-nested case additionally emitted an inside a
+# (invalid HTML). These pin the fix: items nest as
- ...
+# (valid HTML), for unordered, ordered, mixed and task lists at arbitrary depth.
+
+
+def test_nested_unordered_list_nests_sublist_inside_parent_li(node_available):
+ html = _run_markdown_case(
+ "- Item one\n"
+ "- Item two\n"
+ " - Nested sub-item"
+ )
+
+ # The sublist lives INSIDE the parent - , not loose after it.
+ assert (
+ ""
+ ) in html
+ # Exactly one level of nesting (two ).
+ assert html.count("
") == 2
+ assert html.count("
") == 2
+ # The sub-item is NOT rendered as literal text or a stray paragraph.
+ assert "- Nested sub-item" not in html
+ assert "- Nested sub-item" not in html
+ assert "
" not in html
+ # No leftover sentinels.
+ assert "...
. It must nest inside the
+ # parent as valid HTML instead.
+ html = _run_markdown_case(
+ "1. First\n"
+ "2. Second\n"
+ " 1. Sub"
+ )
+
+ assert (
+ "- First
- Second"
+ "
- Sub
"
+ ) in html
+ assert html.count("") == 2
+ assert html.count("
") == 2
+ # No is ever wrapped in / adjacent-inside a paragraph.
+ assert "" not in html
+ assert "" not in html
+ assert "" not in html # no surrounding prose in this sample
+ assert "
" not in html
+
+
+def test_mixed_unordered_under_ordered_nests_ul_inside_ol_li(node_available):
+ html = _run_markdown_case(
+ "1. Parent\n"
+ " - bullet child\n"
+ "2. Second"
+ )
+
+ assert (
+ "- Parent
"
+ "- Second
"
+ ) in html
+ assert html.count("") == 1
+ assert html.count("") == 1
+ assert "" not in html
+
+
+def test_nested_ordered_under_unordered_nests_ol_inside_ul_li(node_available):
+ html = _run_markdown_case(
+ "- Parent bullet\n"
+ " 1. numbered child\n"
+ "- Sibling bullet"
+ )
+
+ assert (
+ "
- Parent bullet
- numbered child
"
+ "- Sibling bullet
"
+ ) in html
+ assert html.count("") == 1
+ assert html.count("") == 1
+
+
+def test_nested_task_checkbox_preserves_class_and_structure(node_available):
+ html = _run_markdown_case(
+ "- [ ] parent task\n"
+ " - [x] child done"
+ )
+
+ # Parent keeps its task-item - , child keeps task-done, and the child list
+ # is nested inside the parent
- .
+ assert (
+ '
- '
+ ''
+ 'parent task'
+ '
- '
+ ''
+ 'child done'
+ "
"
+ ) in html
+ assert html.count("") == 2
+ assert "task-text\">child done" in html
+
+
+def test_three_level_deep_nesting(node_available):
+ html = _run_markdown_case(
+ "- A\n"
+ " - B\n"
+ " - C"
+ )
+
+ assert (
+ ""
+ ) in html
+ assert html.count("") == 3
+ assert html.count("
") == 3
+
+
+def test_inconsistent_indent_widths_still_nest_consistently(node_available):
+ # Models emit inconsistent indent (here 3 then 6 spaces). Depth must follow
+ # the RELATIVE increase, not a fixed bucket width.
+ html = _run_markdown_case(
+ "- top\n"
+ " - mid\n"
+ " - deep"
+ )
+
+ assert (
+ ""
+ ) in html
+ assert html.count("") == 3
+
+
+def test_nested_list_after_prose_keeps_paragraph_and_indent(node_available):
+ # A lead-in paragraph, then a bullet list whose last item carries a nested
+ # sub-item, then a trailing paragraph.
+ html = _run_markdown_case(
+ "Bullet list:\n\n"
+ "- Item one\n"
+ "- Item two\n"
+ " - Nested sub-item\n\n"
+ "After."
+ )
+
+ assert "Bullet list:
" in html
+ assert "After.
" in html
+ assert (
+ "- Item two
"
+ ) in html
+ # The sub-item never leaks out as a literal-text paragraph (the old bug).
+ assert "- Nested sub-item" not in html
+ assert "- Nested sub-item" not in html
+
+
+def test_flat_unordered_list_still_renders_as_one_ul(node_available):
+ # Guard the flat path the builder must not regress.
+ html = _run_markdown_case("- one\n- two\n- three")
+
+ assert "
" in html
+ assert html.count("") == 1
+ assert html.count("- ") == 3
+ assert "" not in html
+ assert "" not in html