diff --git a/packages/ui/components/BlockRenderer.tsx b/packages/ui/components/BlockRenderer.tsx index fbc72f17..f0c7bbb2 100644 --- a/packages/ui/components/BlockRenderer.tsx +++ b/packages/ui/components/BlockRenderer.tsx @@ -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 (
onToggleCheckbox!(block.id, !isChecked) : undefined} /> - - - + {paragraphs.length === 1 ? ( + + + + ) : ( +
+ {paragraphs.map((para, i) => ( +

0 ? 'mt-3' : ''}> + +

+ ))} +
+ )}
); } diff --git a/packages/ui/components/plan-diff/PlanCleanDiffView.tsx b/packages/ui/components/plan-diff/PlanCleanDiffView.tsx index 6eba8a6c..dff42c06 100644 --- a/packages/ui/components/plan-diff/PlanCleanDiffView.tsx +++ b/packages/ui/components/plan-diff/PlanCleanDiffView.tsx @@ -528,6 +528,7 @@ const InlineModifiedBlock: React.FC = ({ if (wrap.type === "list-item") { const listLevel = wrap.listLevel || 0; const isCheckbox = wrap.checked !== undefined; + const paragraphs = unified.split(/\n\n+/); return (
= ({ orderedIndex={wrap.orderedStart ?? 1} checked={wrap.checked} /> - - - + {paragraphs.length === 1 ? ( + + + + ) : ( +
+ {paragraphs.map((para, i) => ( +

0 ? "mt-3" : ""}> + +

+ ))} +
+ )}
); } @@ -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 (
- - - + {paragraphs.length === 1 ? ( + + + + ) : ( +
+ {paragraphs.map((para, i) => ( +

0 ? "mt-3" : ""}> + +

+ ))} +
+ )}
); } diff --git a/packages/ui/utils/parser.test.ts b/packages/ui/utils/parser.test.ts index 524d24f1..1f0ed8af 100644 --- a/packages/ui/utils/parser.test.ts +++ b/packages/ui/utils/parser.test.ts @@ -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"); diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts index cf64e9d9..2123aea2 100644 --- a/packages/ui/utils/parser.ts +++ b/packages/ui/utils/parser.ts @@ -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; } diff --git a/tests/manual/local/test-loose-list.sh b/tests/manual/local/test-loose-list.sh new file mode 100755 index 00000000..68bc322d --- /dev/null +++ b/tests/manual/local/test-loose-list.sh @@ -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 < 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.