fix: indent loose list continuation content under parent bullet (#705)

Closes #704
This commit is contained in:
Michael Ramos
2026-05-11 16:41:26 -07:00
committed by GitHub
parent c5b8535173
commit fcf2ba4cf5
6 changed files with 351 additions and 14 deletions
+16 -3
View File
@@ -81,6 +81,9 @@ export const BlockRenderer: React.FC<{
? checkboxOverrides.get(block.id)!
: block.checked;
const isInteractive = isCheckbox && !!onToggleCheckbox;
const textClass = `text-sm leading-relaxed ${isCheckbox && isChecked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`;
const paragraphs = block.content.split(/\n\n+/);
const inlineProps = { imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, githubRepo, onNavigateAnchor };
return (
<div
className="flex items-start gap-3 my-1.5"
@@ -95,9 +98,19 @@ export const BlockRenderer: React.FC<{
interactive={isInteractive}
onToggle={isInteractive ? () => onToggleCheckbox!(block.id, !isChecked) : undefined}
/>
<span className={`text-sm leading-relaxed ${isCheckbox && isChecked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`}>
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
</span>
{paragraphs.length === 1 ? (
<span className={textClass}>
<InlineMarkdown {...inlineProps} text={block.content} />
</span>
) : (
<div className={textClass}>
{paragraphs.map((para, i) => (
<p key={i} className={i > 0 ? 'mt-3' : ''}>
<InlineMarkdown {...inlineProps} text={para} />
</p>
))}
</div>
)}
</div>
);
}
@@ -528,6 +528,7 @@ const InlineModifiedBlock: React.FC<InlineModifiedBlockProps> = ({
if (wrap.type === "list-item") {
const listLevel = wrap.listLevel || 0;
const isCheckbox = wrap.checked !== undefined;
const paragraphs = unified.split(/\n\n+/);
return (
<div
data-diff-block-index={index}
@@ -541,9 +542,19 @@ const InlineModifiedBlock: React.FC<InlineModifiedBlockProps> = ({
orderedIndex={wrap.orderedStart ?? 1}
checked={wrap.checked}
/>
<span className={listItemTextClass(isCheckbox, wrap.checked)}>
<InlineMarkdown text={unified} />
</span>
{paragraphs.length === 1 ? (
<span className={listItemTextClass(isCheckbox, wrap.checked)}>
<InlineMarkdown text={unified} />
</span>
) : (
<div className={listItemTextClass(isCheckbox, wrap.checked)}>
{paragraphs.map((para, i) => (
<p key={i} className={i > 0 ? "mt-3" : ""}>
<InlineMarkdown text={para} />
</p>
))}
</div>
)}
</div>
);
}
@@ -620,6 +631,7 @@ const SimpleBlockRenderer: React.FC<{ block: Block; orderedIndex?: number | null
case "list-item": {
const listLevel = block.level || 0;
const isCheckbox = block.checked !== undefined;
const paragraphs = block.content.split(/\n\n+/);
return (
<div
className={LIST_ITEM_ROW_CLASS}
@@ -631,9 +643,19 @@ const SimpleBlockRenderer: React.FC<{ block: Block; orderedIndex?: number | null
orderedIndex={orderedIndex}
checked={block.checked}
/>
<span className={listItemTextClass(isCheckbox, block.checked)}>
<InlineMarkdown text={block.content} />
</span>
{paragraphs.length === 1 ? (
<span className={listItemTextClass(isCheckbox, block.checked)}>
<InlineMarkdown text={block.content} />
</span>
) : (
<div className={listItemTextClass(isCheckbox, block.checked)}>
{paragraphs.map((para, i) => (
<p key={i} className={i > 0 ? "mt-3" : ""}>
<InlineMarkdown text={para} />
</p>
))}
</div>
)}
</div>
);
}
+56 -1
View File
@@ -250,9 +250,64 @@ describe("parseMarkdownToBlocks — list continuation lines", () => {
expect(blocks[1].content).toBe("Not indented");
});
test("blank line between list item and indented text prevents merging", () => {
test("blank line between list item and indented text merges as loose continuation", () => {
const md = "- Item\n\n Indented paragraph";
const blocks = parseMarkdownToBlocks(md);
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe("list-item");
expect(blocks[0].content).toBe("Item\n\nIndented paragraph");
});
test("loose continuation with multiple paragraphs", () => {
const md = "- Item\n\n Para one\n\n Para two";
const blocks = parseMarkdownToBlocks(md);
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe("list-item");
expect(blocks[0].content).toBe("Item\n\nPara one\n\nPara two");
});
test("loose continuation stops at non-indented line", () => {
const md = "- Item\n\n Indented\n\nNot indented";
const blocks = parseMarkdownToBlocks(md);
expect(blocks).toHaveLength(2);
expect(blocks[0].type).toBe("list-item");
expect(blocks[0].content).toBe("Item\n\nIndented");
expect(blocks[1].type).toBe("paragraph");
expect(blocks[1].content).toBe("Not indented");
});
test("loose continuation stops at next list item", () => {
const md = "- First\n\n Body of first\n\n- Second";
const blocks = parseMarkdownToBlocks(md);
expect(blocks).toHaveLength(2);
expect(blocks[0].type).toBe("list-item");
expect(blocks[0].content).toBe("First\n\nBody of first");
expect(blocks[1].type).toBe("list-item");
expect(blocks[1].content).toBe("Second");
});
test("loose continuation works with ordered lists", () => {
const md = "1. First\n\n Body of first\n\n2. Second";
const blocks = parseMarkdownToBlocks(md);
expect(blocks).toHaveLength(2);
expect(blocks[0].type).toBe("list-item");
expect(blocks[0].ordered).toBe(true);
expect(blocks[0].content).toBe("First\n\nBody of first");
expect(blocks[1].type).toBe("list-item");
expect(blocks[1].content).toBe("Second");
});
test("loose continuation with mixed tight and loose lines", () => {
const md = "- Item\n\n Para one\n still para one\n\n Para two";
const blocks = parseMarkdownToBlocks(md);
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe("list-item");
expect(blocks[0].content).toBe("Item\n\nPara one\nstill para one\n\nPara two");
});
test("single-space indent after blank line does not merge (insufficient indentation)", () => {
const md = "- Item\n\n Barely indented";
const blocks = parseMarkdownToBlocks(md);
expect(blocks).toHaveLength(2);
expect(blocks[0].type).toBe("list-item");
expect(blocks[1].type).toBe("paragraph");
+6 -4
View File
@@ -387,15 +387,17 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => {
lastLineWasBlank = true;
continue;
}
// List continuation: indented line after a list item merges into it
// List continuation: indented line after a list item merges into it.
// Tight (no blank line): 1+ whitespace, joined with \n (same paragraph).
// Loose (after blank line): 2+ spaces, joined with \n\n (new paragraph within the item).
if (
!prevLineWasBlank &&
buffer.length === 0 &&
blocks.length > 0 &&
blocks[blocks.length - 1].type === 'list-item' &&
/^\s+/.test(line)
(prevLineWasBlank ? /^\s{2,}/ : /^\s+/).test(line)
) {
blocks[blocks.length - 1].content += '\n' + trimmed;
const sep = prevLineWasBlank ? '\n\n' : '\n';
blocks[blocks.length - 1].content += sep + trimmed;
continue;
}
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# Test script for issue #704: loose list items with blank-line-separated
# continuation content should render indented under their parent bullet.
#
# Usage:
# ./tests/manual/local/test-loose-list.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
FIXTURE="$PROJECT_ROOT/tests/test-fixtures/15-loose-list-items.md"
echo "=== Loose List Items Test (Issue #704) ==="
echo ""
# Build
echo "Building review + hook..."
cd "$PROJECT_ROOT"
bun run build:review
bun run build:hook
echo ""
echo "Starting hook server with loose-list fixture..."
echo "Browser should open automatically."
echo ""
# Read the fixture and escape it for JSON
PLAN_CONTENT=$(python3 -c "
import json, sys
with open('$FIXTURE') as f:
print(json.dumps(f.read()))
")
PLAN_JSON=$(cat <<EOF
{
"tool_input": {
"plan": $PLAN_CONTENT
}
}
EOF
)
echo "$PLAN_JSON" | bun run "$PROJECT_ROOT/apps/hook/server/index.ts"
echo ""
echo "=== Test Complete ==="
+198
View File
@@ -0,0 +1,198 @@
# Loose List Items (Issue #704)
This fixture covers the fix for loose list continuation AND potential regressions. Each section says exactly what you should see. If anything looks wrong, the section title tells you what broke.
---
## 1. Loose bullet points (the fix)
You SHOULD see the paragraph and inline code indented under each bullet, aligned with the bold title text — not flush-left as separate paragraphs.
- **Waffles**
The following method prepares a waffle while enforcing all recommended crispiness checks:
`BatterMixer.run({ mode: "waffle", temp: 400 })`
- **Pancakes**
Pancake preparation differs from waffles in several ways: batter is poured sequentially (there's no waffle iron), flip timing needs explicit handling, and the `BubbleStream` must be observed before advancing to the next pancake.
`GriddleSession.start({ flipDetection: true })`
---
## 2. Loose numbered lists (the fix)
Same as above but with numbers. Each description paragraph SHOULD be indented under its number.
1. **Preheat the griddle**
Set temperature to 375 and wait for the indicator light. The surface should be evenly heated before proceeding.
2. **Pour the batter**
Use a quarter-cup measure for consistent sizing. Pour from the center and let it spread naturally.
3. **Watch for bubbles**
When bubbles form across the surface and the edges look set, flip once. Do not press down on the pancake.
---
## 3. Nested lists inside loose items (the fix)
You SHOULD see "These are the main categories:" indented under the Breakfast bullet. The sub-bullets (Waffles, Pancakes, French toast) should be nested one level deeper than the parent bullet. Same for Lunch.
- **Breakfast items**
These are the main categories:
- Waffles
- Pancakes
- French toast
- **Lunch items**
Served after 11am:
- Sandwiches
- Salads
---
## 4. Tight lists — NO blank lines (regression check)
These should render exactly as before: simple bullets, each on its own line, no extra spacing. If these look broken or have extra gaps, the tight continuation path regressed.
- First tight item
- Second tight item
- Third tight item
---
## 5. Tight continuation — wrapped lines (regression check)
Each bullet below has a second line that wraps without a blank line gap. You SHOULD see each bullet as a single continuous sentence — the second line joins the first, not as a separate paragraph. If the second line appears detached or as its own bullet, tight continuation is broken.
- This is a list item with a long description
that continues on the next line without a blank line
- Another item that also wraps
onto a second line here
- Short item
---
## 6. Single-space tight indent (regression check)
This tests that single-space indentation still works for tight (no blank line) continuation. You SHOULD see one bullet with "Item" followed by "continued" as part of the same text. If "continued" appears as a separate paragraph below the bullet, the tight indent check regressed.
- Item
continued with single space
---
## 7. Non-indented text after a list (regression check)
The paragraph "This paragraph is not indented" SHOULD appear as a normal standalone paragraph below the bullet — NOT indented under the bullet. If it got absorbed into the bullet, the parser is too greedy.
- A bullet point
This paragraph is not indented so it must not be part of the bullet above.
---
## 8. Heading after a blank line under a list item (regression check)
The heading "This Is A Heading" SHOULD appear as a full-width heading, not as continuation content under the bullet. If the heading is indented under the bullet or missing, block-level element detection is broken.
- A bullet point
### This Is A Heading
---
## 9. Code fence after a list item (regression check)
The code block SHOULD appear as its own standalone code block below the bullet — not as inline text absorbed into the bullet. Code fences are block-level elements and must break list continuation.
- A bullet point
```typescript
const x = 1;
```
---
## 10. Horizontal rule after a list item (regression check)
You SHOULD see a bullet, then a visible horizontal line separator below it. The HR must NOT be absorbed into the bullet.
- A bullet point
---
## 11. Blockquote after a list item (regression check)
The blockquote "This is a quote" SHOULD appear as a styled blockquote (with a left border), not as text under the bullet.
- A bullet point
> This is a quote
---
## 12. Table after a list item (regression check)
The table SHOULD render as its own table element below the bullet — not as text inside the bullet.
- A bullet point
| Column A | Column B |
|----------|----------|
| Value 1 | Value 2 |
---
## 13. Multiple blank lines between bullet and continuation (edge case)
You SHOULD see the continuation text indented under the bullet, same as section 1. Multiple blank lines between the bullet and its continuation should still merge.
- A bullet point
This continuation had two blank lines above it but should still be indented under the bullet.
---
## 14. Checkbox lists (regression check)
Checkboxes SHOULD render with their circle/checkmark icons. Checked items should have strikethrough text. If checkboxes look wrong or the text styling is off, the checkbox rendering regressed.
- [ ] Unchecked task item
- [x] Checked task item with strikethrough
- [ ] Another unchecked item
---
## 15. Mixed loose list with checkboxes (edge case)
The description paragraph SHOULD be indented under the checkbox bullet. The checkbox icon and strikethrough behavior should still work correctly.
- [ ] **Setup step**
Install all dependencies and configure the environment before proceeding.
- [x] **Already done**
This step was completed earlier and should show as checked with strikethrough.
---
## 16. Insufficient indent after blank line (regression check)
The text "Barely indented" has only one space of indentation after a blank line. It SHOULD appear as a separate paragraph — NOT absorbed into the bullet. Loose continuation requires 2+ spaces.
- A bullet point
Barely indented with one space — this must be a standalone paragraph, not under the bullet.