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); + } + }); + }); +});