Files
openidea/e2e/agentMarkdown.spec.ts
T
l0ng-ai 89f00a29f9 feat(agents): reference chips, quote blocks and a richer completion in the composer (#3091)
The agent composer is now a CodeMirror editor: the text stays plain and is what's sent, while @ file references are drawn as chips (icon, name, lines) that move and delete as one piece, open on click and preview on hover; Backspace after a chip opens it as text. Quotes show as blocks, and sent messages get the same chips and quote blocks. The @ completion lists open editors first, highlights matches and shows the folder; the focused editor's file is offered above the text, a pasted project path becomes a reference, and dropping files shows a hint. Lines copied from an editor paste as a reference, terminal output as a terminal block, and code blocks show as cards. The reference trimming regex avoids lookbehind for WebKit before 16.4.
2026-11-13 22:29:59 +08:00

549 lines
22 KiB
TypeScript

/** 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: [],
capabilities: { fork: true, rollback: true, steer: true, images: true },
};
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, imageData?: string, theme?: "dark" | "light") {
await page.goto("/");
await page.getByRole("tree", { name: "Project" }).waitFor();
await page.evaluate(
async ({ task, text, imageData, theme }) => {
if (theme) document.documentElement.dataset.theme = theme;
if (imageData) {
const routePath = "/src/platform/tauriCore.ts";
const { setInvokeRoute } = await import(routePath);
setInvokeRoute((cmd: string) => (cmd === "fs_read_image" ? Promise.resolve(imageData) : null));
}
// 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: "user",
text: "Show Markdown",
attachments: [],
sentMs: 0,
doneMs: 1,
steered: false,
},
{ type: "agent", text },
],
],
]),
unread: new Set(),
docked: null,
});
store.dockTask(task.id);
},
{ task: TASK, text, imageData, theme },
);
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");
});
test("loads local reply images, and web ones when asked", async ({ page }) => {
let fetched = 0;
await page.route("https://images.example.com/code.gif", (route) => {
fetched++;
return route.fulfill({
contentType: "image/gif",
body: Buffer.from(PIXEL.split(",")[1]!, "base64"),
});
});
const reply = await openReply(
page,
"![Local QR](/tmp/code.png)\n\n![Web QR](https://images.example.com/code.gif)",
PIXEL,
);
const width = (name: string) =>
reply.getByRole("img", { name, includeHidden: true }).evaluate((img: HTMLImageElement) => img.naturalWidth);
await expect.poll(() => width("Local QR")).toBe(1);
const load = reply.getByRole("button", {
name: "Load image from images.example.com",
});
await expect(load).toBeVisible();
expect(fetched).toBe(0);
await load.click();
await expect.poll(() => width("Web QR")).toBe(1);
expect(fetched).toBe(1);
});
test("copies reply Markdown with the keyboard and keeps code copying separate", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
const reply = await openReply(page, REPLY);
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
const actions = copy.locator("..");
await page.mouse.move(0, 0);
await expect(actions).toHaveCSS("opacity", "1");
await copy.focus();
await expect(actions).toHaveCSS("opacity", "1");
await page.keyboard.press("Enter");
await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(REPLY);
await reply.getByRole("button", { name: "Copy Code", exact: true }).click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("const a = 1;");
});
test("keeps latest reply actions visible and quotes into the draft without shifting content", async ({ page }) => {
const reply = await openReply(page, REPLY);
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
const actions = copy.locator("..");
await page.mouse.move(0, 0);
await expect(actions).toHaveCSS("opacity", "1");
const before = await reply.boundingBox();
await reply.hover();
await expect(actions).toHaveCSS("opacity", "1");
expect(await reply.boundingBox()).toEqual(before);
await expect(actions.getByRole("button")).toHaveCount(4);
await page.mouse.move(0, 0);
await expect(actions).toHaveCSS("opacity", "1");
const userCopy = page.getByRole("button", { name: "Copy", exact: true });
await expect(userCopy.locator("..")).toHaveCSS("opacity", "0");
await page.getByText("Show Markdown", { exact: true }).hover();
await expect(userCopy.locator("..")).toHaveCSS("opacity", "1");
await reply.hover();
await page.getByRole("button", { name: "Quote Reply", exact: true }).click();
const draft = page.getByRole("textbox", { name: "Message", exact: true });
await expect(draft).toBeFocused();
// Quoted as `> ` lines, drawn as a block without their markers, an empty line to type in after it.
const quoted = REPLY.trimEnd().split("\n");
await expect(draft.locator(".cm-line")).toHaveText([...quoted, "", ""]);
await expect(draft.locator(".cm-line[class*='_quoteLine_']")).toHaveCount(quoted.length);
});
test("copies a mouse selection with Ctrl+C and pops up no Quote button", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
const reply = await openReply(page, "Selectable answer");
const box = (await reply.locator("p").first().boundingBox())!;
await page.mouse.move(box.x + 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 2, box.y + box.height / 2);
await page.mouse.up();
await page.keyboard.press("ControlOrMeta+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("Selectable answer");
await expect(page.getByRole("button", { name: "Quote", exact: true })).toHaveCount(0);
});
test("keeps chat history readable and selections free of timestamps and status labels", async ({ page, context }) => {
await page.setViewportSize({ width: 1800, height: 1000 });
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await openReply(page, "Hi! What would you like to work on?");
await page.evaluate(async (task) => {
const path = "/src/features/agents/store.ts";
const store = await import(path);
const user = (text: string) => ({
type: "user",
text,
attachments: [],
sentMs: 1,
doneMs: 5001,
steered: false,
});
store.useAgents.setState({
entries: new Map([
[
task.id,
[
user("hi"),
{ type: "agent", text: "Hi! What would you like to work on?" },
user("im bob"),
{ type: "agent", text: "Hi Bob! What are we working on today?" },
user("yes"),
{ type: "thought", text: "**Requesting task context**" },
{ type: "agent", text: "What would you like me to proceed with?" },
],
],
]),
});
store.openTaskInEditorLocation(task.id);
}, TASK);
const stream = page.locator("[class*='_stream_']:visible");
const replies = stream.locator("[class*='_reply_']");
await expect(replies).toHaveCount(3);
const geometry = await stream.evaluate((root) => {
const reply = root.querySelector<HTMLElement>("[class*='_reply_']")!;
const bubble = root.querySelector<HTMLElement>("[class*='_user_']")!;
const composer = root
.closest("[class*='_conversation_']")!
.querySelector<HTMLElement>("[class*='_composerArea_']")!;
const rr = reply.getBoundingClientRect();
const br = bubble.getBoundingClientRect();
const cr = composer.getBoundingClientRect();
return {
reply: rr.width,
root: root.clientWidth,
bubbleLeft: br.left,
replyLeft: rr.left,
bubbleRight: br.right,
replyRight: rr.right,
columnCenter: (() => {
const column = reply.closest("[class*='_agentMessage_']")!.getBoundingClientRect();
return (column.left + column.right) / 2;
})(),
composerCenter: (cr.left + cr.right) / 2,
};
});
expect(geometry.reply).toBeGreaterThan(0);
expect(geometry.reply).toBeLessThan(geometry.root);
expect(geometry.bubbleLeft).toBeGreaterThan(geometry.replyLeft);
expect(geometry.bubbleRight).toBeGreaterThan(geometry.replyRight);
expect(Math.abs(geometry.columnCenter - geometry.composerCenter)).toBeLessThan(1);
await stream.getByText("hi", { exact: true }).hover();
await expect(stream.locator("[class*='_messageTime_']").first()).toBeVisible();
const selected = await stream.evaluate((root) => {
const range = document.createRange();
range.selectNodeContents(root);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
return selection.toString();
});
expect(selected).toContain("hi");
expect(selected).toContain("Hi Bob!");
expect(selected).toContain("What would you like me to proceed with?");
expect(selected).not.toMatch(/AM|PM|5s|Reasoning|Requesting task context/);
await page.keyboard.press("ControlOrMeta+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(selected);
// The finished turn's reasoning is folded with its work.
await stream.locator("[class*='_turnFold_'] > summary").click();
await stream.locator("[class*='_thought_'] > summary").click();
await expect(stream.locator("[class*='_thoughtPreview_']")).toHaveText("Requesting task context");
await expect(stream.locator("[class*='_thoughtBody_'] strong")).toHaveText("Requesting task context");
await page.evaluate(async (id) => {
const path = "/src/features/agents/store.ts";
const store = await import(path);
store.dockTask(id);
}, TASK.id);
await expect(stream).toBeVisible();
const narrow = await stream.evaluate((root) => ({
width: root.clientWidth,
content: root.scrollWidth,
reply: root.querySelector<HTMLElement>("[class*='_reply_']")!.getBoundingClientRect().width,
padding:
Number.parseFloat(getComputedStyle(root).paddingLeft) + Number.parseFloat(getComputedStyle(root).paddingRight),
}));
expect(narrow.width).toBeGreaterThan(0);
expect(narrow.content).toBeLessThanOrEqual(narrow.width);
expect(narrow.reply).toBeLessThanOrEqual((narrow.width - narrow.padding) * 0.96);
});
test("keeps hover actions reachable when the pointer leaves the message body", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
const reply = await openReply(page, "A short answer.");
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
await reply.hover();
const box = await copy.boundingBox();
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, {
steps: 20,
});
await expect(copy.locator("..")).toHaveCSS("opacity", "1");
await copy.click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("A short answer.");
});
test("selects through code blocks and tables while keeping copy controls outside the code", async ({
page,
context,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
const tree = "rust/\n├── Cargo.toml\n└── src/\n └── main.rs";
const flow = "内置文本 SAMPLE\n ↓ parser::parse\nVec<Item>\n ↓ Inventory::add\n终端输出";
const text = `项目结构:\n\n\`\`\`text\n${tree}\n\`\`\`\n\n| 模块 | 职责 |\n| --- | --- |\n| main.rs | 程序入口 |\n\n数据流是:\n\n\`\`\`text\n${flow}\n\`\`\``;
const reply = await openReply(page, text);
await expect(reply.getByRole("button", { name: "Copy Code", exact: true })).toHaveCount(2);
const selected = await reply.evaluate((root) => {
const codes = [...root.querySelectorAll("pre > code")];
if (codes.some((code) => code.parentElement!.querySelector("button"))) throw new Error("Control in code selection");
const start = root.querySelector("p")!.firstChild!;
const end = codes.at(-1)!.lastChild!;
window.getSelection()!.setBaseAndExtent(start, 0, end, end.textContent!.length);
return window.getSelection()!.toString();
});
expect(selected).toContain(tree);
expect(selected).toContain("程序入口");
expect(selected).toContain(flow);
await page.keyboard.press("ControlOrMeta+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(selected);
await page.evaluate(() => window.getSelection()!.removeAllRanges());
const copies = reply.getByRole("button", { name: "Copy Code", exact: true });
await copies.first().click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(tree);
await copies.last().click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(flow);
});
test("keeps mermaid `<style>` rules scoped to the rendered svg after id rewriting", async ({ page }) => {
const diagram = [
"```mermaid",
"%%{init: {'theme': 'base', 'flowchart': {'htmlLabels': false, 'curve': 'basis'}}}%%",
"flowchart TB",
' subgraph T1["Tier 1"]',
" A[(Local)] <--> B[(Peer)]",
" end",
' subgraph T2["Tier 2"]',
" C[(Host RAM)]",
" end",
' C -->|"PCIe"| A',
"```",
].join("\n");
const reply = await openReply(page, `Diagram:\n\n${diagram}`);
await expect(reply.locator("[data-mermaid-state='ok'] .mermaid-output svg")).toBeVisible();
expect(await css(reply, ".cluster rect", "fill")).not.toBe("rgb(0, 0, 0)");
expect(await css(reply, ".flowchart-link", "fill")).toBe("none");
});
test("renders KaTeX math in mermaid labels", async ({ page }) => {
const diagram = ["```mermaid", "flowchart LR", ' A["$$x^2 + \\frac{a}{b}$$"] --> B', "```"].join("\n");
const reply = await openReply(page, `Diagram:\n\n${diagram}`);
const svg = reply.locator("[data-mermaid-state='ok'] .mermaid-output svg");
await expect(svg).toBeVisible();
await expect(svg.locator("math mfrac")).toHaveCount(1);
});
test("keeps mermaid flowchart nodes and sequence control blocks off solid black", async ({ page }) => {
const flow = [
"```mermaid",
"flowchart LR",
' subgraph Client["Client"]',
' A["Local agent"]',
' B["Voice input"]',
" end",
' subgraph Cloud["Cloud"]',
' C["Cloud agent"]',
' D["Memory service"]',
" end",
" B --> A",
" A --> C",
" C --> D",
"```",
].join("\n");
const sequence = ["```mermaid", "sequenceDiagram", " loop every minute", " A->>B: ping", " end", "```"].join(
"\n",
);
const reply = await openReply(page, `Flow:\n\n${flow}\n\nSequence:\n\n${sequence}`);
await expect(reply.locator("[data-mermaid-state='ok'] .mermaid-output svg")).toHaveCount(2);
expect(await css(reply, ".node rect", "fill")).not.toBe("rgb(0, 0, 0)");
expect(await css(reply, ".cluster rect", "fill")).not.toBe("rgb(0, 0, 0)");
expect(await css(reply, ".labelBox", "fill")).not.toBe("rgb(0, 0, 0)");
});
const DIAGRAMS = [
"flowchart LR\n subgraph Client\n A[Local agent] --> B[Voice input]\n end",
"sequenceDiagram\n loop every minute\n A->>B: ping\n end",
"classDiagram\n class Agent {\n +String name\n +run()\n }",
"stateDiagram-v2\n [*] --> Ready\n Ready --> Running\n Running --> [*]",
'pie title Work\n "Ready" : 40\n "Running" : 35\n "Done" : 25',
"gantt\n dateFormat YYYY-MM-DD\n section Delivery\n Shipped :done, shipped, 2026-01-01, 5d\n Building :active, building, after shipped, 5d\n Next :next, after building, 5d\n Critical :crit, critical, after next, 5d",
]
.map((source) => `\`\`\`mermaid\n${source}\n\`\`\``)
.join("\n\n");
for (const theme of ["dark", "light"] as const) {
test(`keeps Mermaid shapes, labels and chart series readable in ${theme} theme`, async ({ page }) => {
const reply = await openReply(page, `Diagrams:\n\n${DIAGRAMS}`, undefined, theme);
const svgs = reply.locator("[data-mermaid-state='ok'] .mermaid-output svg");
await expect(svgs).toHaveCount(6);
for (const [index, selector] of [
[0, ".node rect"],
[1, ".labelBox"],
[2, ".outer-path path"],
[3, ".node rect"],
] as const) {
expect(await css(svgs.nth(index), selector, "fill")).not.toBe("rgb(0, 0, 0)");
expect(await css(svgs.nth(index), selector, "stroke")).not.toContain("url(");
}
expect(await css(svgs.nth(2), ".nodeLabel", "color")).not.toBe(await css(svgs.nth(2), ".outer-path path", "fill"));
const slices = await svgs
.nth(4)
.locator(".pieCircle")
.evaluateAll((els) => els.map((el) => getComputedStyle(el).fill));
expect(new Set(slices).size).toBe(3);
expect(slices).not.toContain("rgb(0, 0, 0)");
const contrasts = await svgs
.nth(5)
.locator("text.taskText")
.evaluateAll((els) => {
const luminance = (color: string) => {
const [r, g, b] = color
.match(/\d+/g)!
.map(Number)
.map((v) => {
const c = v / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
};
return els.map((el) => {
const bar = document.getElementById(el.id.replace(/-text$/, ""))!;
const fg = luminance(getComputedStyle(el).fill);
const bg = luminance(getComputedStyle(bar).fill);
return {
text: el.textContent,
ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05),
};
});
});
expect(contrasts).toHaveLength(4);
for (const { text, ratio } of contrasts) expect(ratio, `${theme}: ${text}`).toBeGreaterThanOrEqual(4.5);
});
}
test("draws a diagram without reaching the hosts its configuration names", async ({ page }) => {
const asked: string[] = [];
await page.route(/^https:\/\/([a-z\d-]+\.)*example\.com\//, (route) => {
asked.push(new URL(route.request().url()).pathname);
return route.fulfill({
contentType: "image/gif",
body: Buffer.from(PIXEL.split(",")[1]!, "base64"),
});
});
const at = (name: string) => `https://images.example.com/${name}`;
const fence = (...lines: string[]) => ["```mermaid", ...lines, "```"].join("\n");
const diagrams = [
fence(
`%%{init: {"themeCSS": ".nodeLabel{background-image:url(${at("theme-css")})} svg{background-image:url(${at("theme-css-root")})}"}}%%`,
"flowchart TD",
" A[One]-->B[Two]",
),
fence(
`%%{init: {"fontFamily": "x;background-image:url(${at("font-family")})"}}%%`,
"flowchart TD",
" A[One]-->B[Two]",
),
fence(
"---",
"config:",
` themeCSS: ".nodeLabel{background-image:url(${at("front-matter")})}"`,
"---",
"flowchart TD",
" A[One]-->B[Two]",
),
];
const reply = await openReply(page, `Diagrams:\n\n${diagrams.join("\n\n")}`);
await expect(reply.locator("[data-mermaid-state='ok'] .mermaid-output svg")).toHaveCount(diagrams.length);
await page.evaluate(
(src) =>
new Promise<void>((done) => {
const img = new Image();
img.onload = img.onerror = () => done();
img.src = src;
}),
"https://example.com/after",
);
expect(asked).toEqual(["/after"]);
});