From ff04dc2d2454bc85a1f5a3f3f66acabc39dae0ca Mon Sep 17 00:00:00 2001 From: bluzername Date: Tue, 15 Sep 2026 07:38:57 +0300 Subject: [PATCH 1/2] Give agent-markdown paragraphs a bottom margin A reply with several sections separated by blank lines show up as one dense block, no visible gap between them. The paragraph breaks are real in the text (each becomes its own

), they just have no space under them. Cause: Streamdown own heading, blockquote and hr components each carry a margin class already (mt-6/mb-2, my-4, my-6), but its default paragraph component is a plain

with no spacing class at all. So with margin reset by Tailwind preflight, every paragraph sit flush against the next one. Add margin-bottom to .agent-markdown p, and zero it on the last paragraph in a block so it does not add extra space before the next message. Test: no jsdom/component test exist in this repo yet (vitest only picks up src/**/*.test.ts and the harness modules are tested as pure functions), so the test check the shipped CSS rule directly against the stylesheet source instead of a rendered DOM. It fails without the fix and pass with it. Fixes #218 --- src/index.css | 13 +++++++ src/surfaces/agentMarkdownSpacing.test.ts | 45 +++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/surfaces/agentMarkdownSpacing.test.ts diff --git a/src/index.css b/src/index.css index 3c06f428..6d19f880 100644 --- a/src/index.css +++ b/src/index.css @@ -776,6 +776,19 @@ header[data-tauri-drag-region] button { color: color-mix(in srgb, var(--color-content) 78%, transparent); } +/* Streamdown's default heading, blockquote and hr components each carry + their own margin (mt-6/mb-2, my-4, my-6), but its paragraph component is + plain

, no spacing class. Without this, every "\n\n" in a reply still + starts a new

(the break is real, see AgentMarkdown), it just has no + visible gap, so a multi-paragraph reply reads as one dense block. */ +.agent-markdown p { + margin-bottom: 1rem; +} + +.agent-markdown p:last-child { + margin-bottom: 0; +} + .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..41db4ea4 --- /dev/null +++ b/src/surfaces/agentMarkdownSpacing.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +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. Streamdown's own + * heading, blockquote and hr components each carry a margin class (mt-6/ + * mb-2, my-4, my-6 — see node_modules/streamdown/dist/index.js), but its + * default paragraph component is a plain `

` with no spacing class at + * all, so a "\n\n" break landed as a real new

with no visible gap. + * + * There's no jsdom/component-render test in this repo (vitest.config.ts + * only picks up src/**\/*.test.ts, and CONTRIBUTING calls out pure-function + * protocol tests as the norm), so this checks the shipped rule directly + * against the stylesheet source rather than a rendered DOM. + */ + +const CSS_PATH = fileURLToPath(new URL("../index.css", import.meta.url)); + +function readCss(): string { + return readFileSync(CSS_PATH, "utf8"); +} + +describe("agent-markdown paragraph spacing", () => { + it("gives every paragraph a visible gap below it", () => { + const css = readCss(); + const rule = css.match(/\.agent-markdown p\s*\{([^}]*)\}/); + expect(rule, "expected a `.agent-markdown p { ... }` rule in index.css").toBeTruthy(); + const body = rule![1]; + const margin = body.match(/margin(?:-bottom)?\s*:\s*([^;]+);/); + expect(margin, "expected a margin-bottom declaration on .agent-markdown p").toBeTruthy(); + expect(margin![1].trim()).not.toBe("0"); + }); + + it("does not leave a trailing gap after the last paragraph in a block", () => { + const css = readCss(); + const rule = css.match(/\.agent-markdown p:last-child\s*\{([^}]*)\}/); + expect( + rule, + "expected a `.agent-markdown p:last-child { ... }` rule zeroing the trailing margin", + ).toBeTruthy(); + expect(rule![1]).toMatch(/margin-bottom\s*:\s*0\s*;/); + }); +}); From 89bc2685c4b83d2d29b5af34bf39008695ee13aa Mon Sep 17 00:00:00 2001 From: bluzername Date: Tue, 15 Sep 2026 14:04:14 +0300 Subject: [PATCH 2/2] Space agent-markdown blocks through Streamdown's dir wrappers The first patch on this branch did nothing. I rendered it and measured it: the gap between paragraphs stayed 0px and the two screenshots came out byte identical. Cause: 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 still space correctly because their margin sits on the element itself. Paragraphs and lists carry no margin at all, so they sit flush. That same wrapper is why the first patch was useless: every

is the only child of its wrapper, so .agent-markdown p:last-child matched all of them and zeroed the margin-bottom it had just added. Put the gap on the block inside the wrapper instead, top side only, so a reply never ends with a trailing gap. Measured in Chromium at 860px wide, dark theme: paragraph gap 0px before, 16px after, nothing added above the first block or below the last. Test: renders real Streamdown output (react-dom/server under happy-dom) and checks the shipped selectors against that DOM, so a rule that matches nothing, or matches every paragraph, fails. It is red on main and red on the first patch, green now. Fixes #218 --- src/index.css | 24 ++-- src/surfaces/agentMarkdownSpacing.test.ts | 148 +++++++++++++++++----- 2 files changed, 131 insertions(+), 41 deletions(-) diff --git a/src/index.css b/src/index.css index 6d19f880..17a26ea1 100644 --- a/src/index.css +++ b/src/index.css @@ -776,17 +776,19 @@ header[data-tauri-drag-region] button { color: color-mix(in srgb, var(--color-content) 78%, transparent); } -/* Streamdown's default heading, blockquote and hr components each carry - their own margin (mt-6/mb-2, my-4, my-6), but its paragraph component is - plain

, no spacing class. Without this, every "\n\n" in a reply still - starts a new

(the break is real, see AgentMarkdown), it just has no - visible gap, so a multi-paragraph reply reads as one dense block. */ -.agent-markdown p { - margin-bottom: 1rem; -} - -.agent-markdown p:last-child { - margin-bottom: 0; +/* 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, diff --git a/src/surfaces/agentMarkdownSpacing.test.ts b/src/surfaces/agentMarkdownSpacing.test.ts index 41db4ea4..a90d4761 100644 --- a/src/surfaces/agentMarkdownSpacing.test.ts +++ b/src/surfaces/agentMarkdownSpacing.test.ts @@ -1,45 +1,133 @@ +// @vitest-environment happy-dom import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; +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. Streamdown's own - * heading, blockquote and hr components each carry a margin class (mt-6/ - * mb-2, my-4, my-6 — see node_modules/streamdown/dist/index.js), but its - * default paragraph component is a plain `

` with no spacing class at - * all, so a "\n\n" break landed as a real new

with no visible gap. + * https://github.com/hardbeat920/monocode/issues/218 - a reply with several + * "\n\n"-separated sections rendered as one dense block. * - * There's no jsdom/component-render test in this repo (vitest.config.ts - * only picks up src/**\/*.test.ts, and CONTRIBUTING calls out pure-function - * protocol tests as the norm), so this checks the shipped rule directly - * against the stylesheet source rather than a rendered DOM. + * 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. */ -const CSS_PATH = fileURLToPath(new URL("../index.css", import.meta.url)); +// 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"; -function readCss(): string { - return readFileSync(CSS_PATH, "utf8"); +/** 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("gives every paragraph a visible gap below it", () => { - const css = readCss(); - const rule = css.match(/\.agent-markdown p\s*\{([^}]*)\}/); - expect(rule, "expected a `.agent-markdown p { ... }` rule in index.css").toBeTruthy(); - const body = rule![1]; - const margin = body.match(/margin(?:-bottom)?\s*:\s*([^;]+);/); - expect(margin, "expected a margin-bottom declaration on .agent-markdown p").toBeTruthy(); - expect(margin![1].trim()).not.toBe("0"); + 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("does not leave a trailing gap after the last paragraph in a block", () => { - const css = readCss(); - const rule = css.match(/\.agent-markdown p:last-child\s*\{([^}]*)\}/); - expect( - rule, - "expected a `.agent-markdown p:last-child { ... }` rule zeroing the trailing margin", - ).toBeTruthy(); - expect(rule![1]).toMatch(/margin-bottom\s*:\s*0\s*;/); + 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); + } + }); }); });