fix(agents): style tables, headings and quotes in agent messages (#1324)

Agent replies only styled paragraphs, lists and code, so tables, headings, quotes, rules, images and task lists fell back to browser defaults: a 26px h1, a quote indented 40px, table columns squeezed to a few letters by overflow-wrap. They now get chat-sized styles from tokens; wide tables scroll sideways.

Closes #1273

Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com>
This commit is contained in:
ntbang0901
2026-11-05 00:50:00 +08:00
committed by GitHub
parent d0910d3313
commit f4fa2b42f6
2 changed files with 185 additions and 0 deletions
+130
View File
@@ -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<Page["locator"]>, 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");
});
+55
View File
@@ -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 {