diff --git a/src/index.css b/src/index.css
index 3c06f428..17a26ea1 100644
--- a/src/index.css
+++ b/src/index.css
@@ -776,6 +776,21 @@ header[data-tauri-drag-region] button {
color: color-mix(in srgb, var(--color-content) 78%, transparent);
}
+/* AgentMarkdown passes dir="auto", so Streamdown puts every block in its own
+
wrapper. A display:contents box
+ drops its own margins, so Streamdown's `space-y-4` on the root (it targets
+ those wrappers) paints no gap at all. Headings, blockquotes and rules keep
+ their spacing because their margin sits on the element itself (mt-6/mb-2,
+ my-4, my-6); paragraphs and lists carry none, so every "\n\n" started a new
+
with no visible gap and the reply read as one dense block (#218).
+ Put the gap back on the block inside the wrapper, top side only, so a reply
+ never ends with a trailing gap. */
+.agent-markdown > div + div > p,
+.agent-markdown > div + div > [data-streamdown="unordered-list"],
+.agent-markdown > div + div > [data-streamdown="ordered-list"] {
+ margin-top: 1rem;
+}
+
.agent-markdown p strong,
.agent-markdown p em,
.agent-markdown p [data-streamdown="strong"] {
diff --git a/src/surfaces/agentMarkdownSpacing.test.ts b/src/surfaces/agentMarkdownSpacing.test.ts
new file mode 100644
index 00000000..a90d4761
--- /dev/null
+++ b/src/surfaces/agentMarkdownSpacing.test.ts
@@ -0,0 +1,133 @@
+// @vitest-environment happy-dom
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { Streamdown } from "streamdown";
+import { describe, expect, it } from "vitest";
+
+/**
+ * https://github.com/hardbeat920/monocode/issues/218 - a reply with several
+ * "\n\n"-separated sections rendered as one dense block.
+ *
+ * AgentMarkdown passes dir="auto", so Streamdown puts every block in its own
+ * `
`. A display:contents box drops its
+ * own margins, so Streamdown's `space-y-4` on the root, which targets exactly
+ * those wrappers, paints no gap. Headings, blockquotes and rules were fine
+ * because their margin sits on the element itself; paragraphs and lists carry
+ * none, so consecutive ones sat flush.
+ *
+ * The same wrapper is why a `.agent-markdown p:last-child` reset is useless
+ * here: every paragraph is the only child of its wrapper, so such a rule
+ * matches all of them and cancels the spacing again. This test renders the
+ * real Streamdown output and checks the shipped selectors against it, so a
+ * rule that matches nothing, or matches every paragraph, fails.
+ */
+
+// happy-dom rewrites import.meta.url, so resolve from the vitest root instead.
+const CSS_PATH = resolve(process.cwd(), "src/index.css");
+
+const SAMPLE = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.";
+
+function renderAgentMarkdown(): string {
+ return renderToStaticMarkup(
+ createElement(
+ Streamdown,
+ { dir: "auto", className: "agent-markdown" },
+ SAMPLE,
+ ),
+ );
+}
+
+type Rule = { selectors: string[]; body: string };
+
+function cssRules(): Rule[] {
+ const css = readFileSync(CSS_PATH, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
+ const rules: Rule[] = [];
+ for (const match of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
+ rules.push({
+ selectors: match[1].split(",").map((part) => part.trim()),
+ body: match[2],
+ });
+ }
+ return rules;
+}
+
+type Side = "top" | "bottom";
+
+/** The margin index.css ends up declaring on one side of `el`, or null. */
+function declaredMargin(el: Element, side: Side): string | null {
+ const longhand = side === "top" ? "margin-top" : "margin-bottom";
+ const logical = side === "top" ? "margin-block-start" : "margin-block-end";
+ let value: string | null = null;
+
+ for (const rule of cssRules()) {
+ const matches = rule.selectors.some((selector) => {
+ try {
+ return el.matches(selector);
+ } catch {
+ return false;
+ }
+ });
+ if (!matches) continue;
+
+ for (const declaration of rule.body.split(";")) {
+ const [rawProperty, rawValue] = declaration.split(":");
+ if (!rawValue) continue;
+ const property = rawProperty.trim();
+ const parts = rawValue.trim().split(/\s+/);
+ if (property === longhand || property === logical) value = parts[0];
+ else if (property === "margin-block")
+ value = side === "top" ? parts[0] : (parts[1] ?? parts[0]);
+ else if (property === "margin")
+ value = side === "top" ? parts[0] : (parts[2] ?? parts[0]);
+ }
+ }
+ return value;
+}
+
+function isZero(margin: string | null): boolean {
+ return margin === null || /^0[a-z%]*$/.test(margin);
+}
+
+describe("agent-markdown paragraph spacing", () => {
+ it("wraps each block in a display:contents div, so space-y-4 cannot space them", () => {
+ document.body.innerHTML = renderAgentMarkdown();
+ const root = document.querySelector(".agent-markdown")!;
+ const paragraphs = [...root.querySelectorAll("p")];
+
+ expect(root.className).toContain("space-y-4");
+ expect(paragraphs).toHaveLength(3);
+ for (const paragraph of paragraphs) {
+ const wrapper = paragraph.parentElement!;
+ expect(wrapper.getAttribute("style")).toContain("display:contents");
+ expect(wrapper.parentElement).toBe(root);
+ }
+ });
+
+ it("leaves a gap between consecutive paragraphs and none after the last", () => {
+ document.body.innerHTML = renderAgentMarkdown();
+ const paragraphs = [
+ ...document.querySelectorAll(".agent-markdown p"),
+ ];
+
+ paragraphs.forEach((paragraph, index) => {
+ const above = declaredMargin(paragraph, "top");
+ const below = declaredMargin(paragraph, "bottom");
+
+ if (index > 0) {
+ const previousBelow = declaredMargin(paragraphs[index - 1], "bottom");
+ expect(
+ !isZero(above) || !isZero(previousBelow),
+ "expected a gap between two paragraphs",
+ ).toBe(true);
+ } else {
+ expect(isZero(above), "no gap above the first paragraph").toBe(true);
+ }
+
+ if (index === paragraphs.length - 1) {
+ expect(isZero(below), "no gap after the last paragraph").toBe(true);
+ }
+ });
+ });
+});