diff --git a/e2e/agentMarkdown.spec.ts b/e2e/agentMarkdown.spec.ts new file mode 100644 index 00000000..da15e0e3 --- /dev/null +++ b/e2e/agentMarkdown.spec.ts @@ -0,0 +1,130 @@ +/** Markdown in agent replies, in the real conversation: browser defaults don't suit a narrow chat. */ +import { expect, test, type Page } from "@playwright/test"; + +const TASK = { + id: "t1", + title: "Repro task", + status: "ready" as const, + isolation: "local" as const, + provider: "codex", + model: null, + cwd: "/dev/acme-api", + branch: null, + base: null, + baseCommit: null, + error: null, + createdMs: 0, + updatedMs: 0, + config: [], + commands: [], + usage: null, + queue: [], +}; + +const PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; +const REPLY = [ + "# Heading one", + "", + "## Heading two", + "", + "##### Heading five", + "", + "> A quote", + "", + "- [x] done", + "- [ ] open", + "", + "---", + "", + `![pixel](${PIXEL})`, + "", + "| Path | Owner | Status | Notes |", + "| --- | --- | --- | --- |", + "| src/features/agents/conversation/very/long/path/file.tsx | someone | modified | a fairly long note |", + "", + "```ts", + "const a = 1;", + "```", +].join("\n"); + +async function openReply(page: Page, text: string) { + await page.goto("/"); + await page.getByRole("tree", { name: "Project" }).waitFor(); + await page.evaluate( + async ({ task, text }) => { + // Paths go through variables: knip can't resolve these browser-absolute URLs. + const storePath = "/src/features/agents/store.ts"; + const store = await import(storePath); + store.useAgents.setState({ + loaded: true, + error: null, + providers: [], + tasks: new Map([[task.id, task]]), + entries: new Map([[task.id, [{ type: "agent", text }]]]), + unread: new Set(), + docked: null, + }); + store.dockTask(task.id); + }, + { task: TASK, text }, + ); + const reply = page.locator("[class*='_markdown_']").first(); + await reply.locator("h1, p").first().waitFor(); + return reply; +} + +const css = (reply: ReturnType, selector: string, property: string) => + reply + .locator(selector) + .first() + .evaluate((el, name) => getComputedStyle(el).getPropertyValue(name), property); +const px = (value: string) => Number.parseFloat(value); + +test("styles tables so a reply reads as a table", async ({ page }) => { + const reply = await openReply(page, REPLY); + expect(await css(reply, "td", "border-top-width")).toBe("1px"); + expect(await css(reply, "td", "padding-left")).toBe("8px"); + expect(await css(reply, "th", "border-top-width")).toBe("1px"); + // A narrow chat must not squeeze a column to a few letters. + const widths = await reply + .locator("td") + .evaluateAll((cells) => cells.map((cell) => cell.getBoundingClientRect().width)); + for (const width of widths) expect(width).toBeGreaterThanOrEqual(70); +}); + +test("scrolls a wide table inside the reply", async ({ page }) => { + const reply = await openReply(page, REPLY); + const sizes = await reply.evaluate((el) => { + const table = el.querySelector("table")!; + return { reply: el.clientWidth, table: table.clientWidth, content: table.scrollWidth }; + }); + expect(sizes.table).toBeLessThanOrEqual(sizes.reply); + expect(sizes.content).toBeGreaterThan(sizes.table); +}); + +test("sizes headings for a chat and keeps small ones readable", async ({ page }) => { + const reply = await openReply(page, REPLY); + const body = px(await css(reply, "td", "font-size")); + const h1 = px(await css(reply, "h1", "font-size")); + expect(h1).toBeGreaterThan(body); + expect(h1).toBeLessThanOrEqual(body * 1.2); + expect(px(await css(reply, "h2", "font-size"))).toBeLessThan(h1); + expect(px(await css(reply, "h5", "font-size"))).toBe(body); +}); + +test("starts and ends a reply without stray margins", async ({ page }) => { + const reply = await openReply(page, REPLY); + expect(await css(reply, "> :first-child", "margin-top")).toBe("0px"); + expect(await css(reply, "> :last-child", "margin-bottom")).toBe("0px"); +}); + +test("styles quotes, rules, images and task lists", async ({ page }) => { + const reply = await openReply(page, REPLY); + expect(await css(reply, "blockquote", "margin-left")).toBe("0px"); + expect(await css(reply, "blockquote", "border-left-width")).toBe("3px"); + expect(await css(reply, "hr", "border-left-width")).toBe("0px"); + expect(await css(reply, "hr", "border-top-width")).toBe("1px"); + expect(await css(reply, "img", "max-width")).toBe("100%"); + // A checkbox stands in for the bullet. + expect(await css(reply, "li:has(> input)", "list-style-type")).toBe("none"); +}); diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index 8b18605d..56bc0c96 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -262,6 +262,7 @@ background: var(--bg-elev); } .markdown :global(pre) { + margin: 0 0 8px; padding: 8px 10px; border-radius: var(--radius-small); background: var(--bg-elev); @@ -352,6 +353,60 @@ .markdown :global(a) { color: var(--link); } +.markdown :global(table) { + display: block; + margin: 0 0 8px; + overflow: auto; + border-collapse: collapse; +} +.markdown :global(th), +.markdown :global(td) { + /* `overflow-wrap: anywhere` from .markdown would otherwise squeeze a column to a few letters. */ + min-width: 6em; + border: 1px solid var(--border); + padding: 4px 8px; +} +.markdown :global(h1), +.markdown :global(h2), +.markdown :global(h3), +.markdown :global(h4), +.markdown :global(h5), +.markdown :global(h6) { + margin: 12px 0 6px; + color: var(--fg-strong); + font-size: 1em; + line-height: 1.3; +} +.markdown :global(h1) { + font-size: 1.15em; +} +.markdown :global(h2) { + font-size: 1.08em; +} +.markdown :global(blockquote) { + margin: 0 0 8px; + padding: 0 10px; + border-left: 3px solid var(--border-strong); + color: var(--fg-secondary); +} +.markdown :global(hr) { + margin: 8px 0; + border: 0; + border-top: 1px solid var(--border); +} +.markdown :global(img) { + max-width: 100%; +} +.markdown :global(li:has(> input[type="checkbox"])) { + margin-left: -20px; + list-style: none; +} +.markdown > :global(:first-child) { + margin-top: 0; +} +.markdown > :global(:last-child) { + margin-bottom: 0; +} .thought summary, .toolDetails > summary {