Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
<div dir="..." style="display: contents"> 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
<p> 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"] {
Expand Down
133 changes: 133 additions & 0 deletions src/surfaces/agentMarkdownSpacing.test.ts
Original file line number Diff line number Diff line change
@@ -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
* `<div dir="..." style="display: contents">`. 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<HTMLElement>(".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);
}
});
});
});
Loading