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.