diff --git a/apps/desktop/e2e/conversation-rendering.spec.ts b/apps/desktop/e2e/conversation-rendering.spec.ts
new file mode 100644
index 00000000..95f8bc75
--- /dev/null
+++ b/apps/desktop/e2e/conversation-rendering.spec.ts
@@ -0,0 +1,102 @@
+import { test, expect, type ElectronApplication, type Page } from "@playwright/test";
+import fs from "node:fs";
+import path from "node:path";
+import { currentRow, items, launch, seedHome, tid } from "./support";
+
+/**
+ * How a reply reads in the transcript (#277). This part covers its Markdown: GitHub-flavoured blocks
+ * render as elements, raw HTML stays text, and nothing in a reply runs, fetches or navigates.
+ */
+let app: ElectronApplication;
+let page: Page;
+let home: string;
+let repo: string;
+const dialogs: string[] = [];
+
+const REPLY = [
+ "## Result",
+ "",
+ "| File | Lines |",
+ "| :--- | ---: |",
+ "| a.ts | 12 |",
+ "",
+ "> Run the suite before pushing.",
+ "",
+ "- [x] parser",
+ "- [ ] renderer",
+ " 1. nested step",
+ "",
+ "```ts",
+ "export const answer = 42;",
+ "```",
+ "",
+ 'Inert: and [a script](javascript:alert(2)) and .',
+].join("\n");
+
+test.beforeAll(async () => {
+ ({ home, repo } = seedHome());
+ ({ app, page } = await launch(home));
+ page.on("dialog", (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss(); });
+ const script = path.join(home, "rendering-script.json");
+ fs.writeFileSync(script, JSON.stringify([{ content: REPLY }]));
+ await page.evaluate((mock_script) => window.modex!.invoke("settings:update", { mock_script }), script);
+ await page.keyboard.press("Meta+n");
+ await expect(tid(page, "composer-input")).toBeFocused();
+ await tid(page, "composer-input").fill("summarize the change");
+ await page.keyboard.press("Meta+Enter");
+ await expect(tid(page, "thread-view")).toBeVisible({ timeout: 15_000 });
+ await expect(items(page, "assistant")).toHaveCount(1);
+ await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
+});
+
+test.afterAll(async () => {
+ await app?.close();
+ fs.rmSync(home, { recursive: true, force: true });
+ fs.rmSync(repo, { recursive: true, force: true });
+});
+
+test("a reply's tables, quotes, task lists and code render as elements", async () => {
+ const answer = items(page, "assistant").first();
+ await expect(answer.getByRole("heading", { name: "Result" })).toBeVisible();
+ const table = answer.locator("table");
+ await expect(table.getByRole("columnheader")).toHaveText(["File", "Lines"]);
+ await expect(table.getByRole("cell")).toHaveText(["a.ts", "12"]);
+ await expect(answer.locator("blockquote")).toHaveText("Run the suite before pushing.");
+ const boxes = answer.getByRole("checkbox");
+ await expect(boxes).toHaveCount(2);
+ await expect(boxes.nth(0)).toBeChecked();
+ await expect(boxes.nth(1)).not.toBeChecked();
+ await expect(boxes.nth(0)).toBeDisabled();
+ await expect(answer.locator("li ol li")).toHaveText("nested step");
+ await expect(answer.locator('pre.code[data-lang="ts"]')).toHaveText("export const answer = 42;");
+});
+
+test("raw HTML, script links and images in a reply stay inert", async () => {
+ const answer = items(page, "assistant").first();
+ await expect(answer).toContainText('');
+ await expect(answer).toContainText("[a script](javascript:alert(2))");
+ await expect(answer.locator("img")).toHaveCount(0);
+ await expect(answer.locator('a[href^="javascript:"]')).toHaveCount(0);
+ await expect(answer.getByRole("link", { name: "Image: chart" })).toHaveAttribute("href", "https://example.com/c.png");
+ expect(dialogs).toEqual([]);
+});
+
+test("Copy on a code block puts exactly its code on the clipboard and starts nothing", async () => {
+ const answer = items(page, "assistant").first();
+ const block = tid(answer, "code-block");
+ await expect(block).toContainText("ts");
+ // A follow-up suggestion can arrive after the turn ends and grow the composer, which moves the
+ // transcript under the pointer mid-click: the likely cause of the one CI run (39112253838) where
+ // Copied never showed after a 42 ms click. A missed click copies nothing, so clicking again until
+ // Copied shows cannot hide a wrong copy.
+ const copy = tid(block, "code-copy");
+ await expect(async () => {
+ await copy.click();
+ await expect(copy).toHaveText("Copied", { timeout: 1000 });
+ }).toPass({ timeout: 15_000 });
+ expect(await app.evaluate(({ clipboard }) => clipboard.readText())).toBe("export const answer = 42;");
+ await expect(items(page, "tool")).toHaveCount(0);
+ await expect(items(page, "approval")).toHaveCount(0);
+ await expect(items(page, "user")).toHaveCount(1);
+ await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
+});
diff --git a/apps/desktop/e2e/markdown-narrow.spec.ts b/apps/desktop/e2e/markdown-narrow.spec.ts
new file mode 100644
index 00000000..4aebb73f
--- /dev/null
+++ b/apps/desktop/e2e/markdown-narrow.spec.ts
@@ -0,0 +1,70 @@
+import { test, expect, type ElectronApplication, type Page } from "@playwright/test";
+import fs from "node:fs";
+import path from "node:path";
+import { currentRow, items, launch, resizeWindow, seedHome, tid } from "./support";
+
+/**
+ * Markdown at the 900x600 minimum (#277): with the Review panel open the transcript is about 265 px
+ * wide. A wide table and a long code line scroll inside their own boxes, and no table column is
+ * squeezed to a word, in every theme.
+ */
+let app: ElectronApplication;
+let page: Page;
+let home: string;
+let repo: string;
+
+test.beforeAll(async () => {
+ ({ home, repo } = seedHome());
+ ({ app, page } = await launch(home));
+ await page.keyboard.press("Meta+n");
+ await expect(tid(page, "composer-input")).toBeFocused();
+});
+
+test.afterAll(async () => {
+ await app?.close();
+ fs.rmSync(home, { recursive: true, force: true });
+ fs.rmSync(repo, { recursive: true, force: true });
+});
+
+const WIDE = [
+ "| Path | Change | Detail |",
+ "| --- | --- | --- |",
+ "| apps/desktop/src/renderer/components/transcript/CodeBlock.tsx | added | " + "a long cell that keeps going ".repeat(8) + "|",
+ "",
+ "```ts",
+ "export const aVeryLongLineOfCode = [" + Array.from({ length: 30 }, (_, i) => `"item-${i}"`).join(", ") + "];",
+ "```",
+].join("\n");
+
+test("a reply's tables and code stay readable in every theme at the 900×600 minimum", async ({}, testInfo) => {
+ const script = path.join(home, "wide-script.json");
+ fs.writeFileSync(script, JSON.stringify([{ content: WIDE }]));
+ await page.evaluate((mock_script) => window.modex!.invoke("settings:update", { mock_script }), script);
+ const replies = await items(page, "assistant").count();
+ await tid(page, "composer-input").fill("show the wide table");
+ await page.keyboard.press("Meta+Enter");
+ // Wait for this reply itself, then for the turn to end: the thread was already idle before it.
+ await expect(items(page, "assistant")).toHaveCount(replies + 1, { timeout: 20_000 });
+ await expect(items(page, "assistant").last()).toContainText("aVeryLongLineOfCode");
+ await expect(currentRow(page)).toHaveAttribute("data-status", "idle", { timeout: 20_000 });
+ const answer = items(page, "assistant").last();
+ await expect(answer.locator("table")).toBeVisible();
+ await resizeWindow(app, page, 900, 600);
+ for (const theme of ["graphite", "jev", "coven"]) {
+ await page.evaluate((value) => window.modex!.invoke("settings:update", { theme: value }), theme);
+ await answer.scrollIntoViewIfNeeded();
+ // The page never scrolls sideways: a wide table and a long code line scroll inside their own boxes.
+ expect(await tid(page, "transcript").evaluate((el) => el.scrollWidth - el.clientWidth), `${theme}: no sideways page scroll`).toBeLessThanOrEqual(0);
+ expect(await answer.locator(".md-table").evaluate((el) => el.scrollWidth > el.clientWidth), `${theme}: the table scrolls in its box`).toBe(true);
+ // No column is squeezed to a word: before cells had a minimum width, Detail measured 58 px and its row 1,009 px tall.
+ const narrowest = await answer.locator("td").evaluateAll((cells) => Math.min(...cells.map((c) => c.getBoundingClientRect().width / parseFloat(getComputedStyle(c).fontSize))));
+ expect(narrowest, `${theme}: every cell is at least about ten characters wide`).toBeGreaterThanOrEqual(9.5);
+ expect(await answer.locator("pre.code").evaluate((el) => el.scrollWidth > el.clientWidth), `${theme}: the code scrolls in its box`).toBe(true);
+ await expect(tid(answer, "code-copy")).toBeVisible();
+ await answer.locator("table").evaluate((table) => table.scrollIntoView({ block: "start" }));
+ const shot = await page.screenshot();
+ await testInfo.attach(`markdown-${theme}-900x600`, { body: shot, contentType: "image/png" });
+ // MODEX_SCREENSHOTS=
keeps them for review evidence.
+ if (process.env.MODEX_SCREENSHOTS) fs.writeFileSync(path.join(process.env.MODEX_SCREENSHOTS, `markdown-${theme}-900x600.png`), shot);
+ }
+});
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 27531935..3cb8bc7c 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -38,12 +38,16 @@
},
"devDependencies": {
"@playwright/test": "^1.63.0",
+ "@types/mdast": "4.0.4",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.1.1",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
+ "mdast-util-from-markdown": "2.0.3",
+ "mdast-util-gfm": "3.1.0",
+ "micromark-extension-gfm": "3.0.0",
"electron": "^44.4.5",
"electron-builder": "26.15.3",
"react": "^19.3.0",
diff --git a/apps/desktop/src/renderer/components/Markdown.tsx b/apps/desktop/src/renderer/components/Markdown.tsx
index b2146721..a1d741dc 100644
--- a/apps/desktop/src/renderer/components/Markdown.tsx
+++ b/apps/desktop/src/renderer/components/Markdown.tsx
@@ -1,59 +1,228 @@
-import { memo, type ReactNode } from "react";
-import { tokenizeInline, type InlineToken } from "../../shared/inline";
+import { memo, useEffect, useState, type ReactNode } from "react";
+import type { Definition, ListItem, Nodes, Parents, PhrasingContent, RootContent, TableRow } from "mdast";
+import { fromMarkdown } from "mdast-util-from-markdown";
+import { gfmFromMarkdown } from "mdast-util-gfm";
+import { gfm } from "micromark-extension-gfm";
+import { safeHref } from "../../shared/inline";
+import { CodeBlock } from "./transcript/CodeBlock";
-/** Small, dependency-free Markdown subset: headings, fenced code, inline code, bold, links, lists, paragraphs. */
+/**
+ * Markdown as the transcript shows it: CommonMark with GitHub's extensions (tables, task lists,
+ * strikethrough, autolinks, footnotes), parsed by micromark and rendered as React elements only.
+ * Raw HTML stays text, only http(s) links are links (main hands them to the system browser), and
+ * images are never fetched. An unclosed code fence runs to the end, so a streaming fence shows its
+ * code so far.
+ */
export const Markdown = memo(function Markdown({ text }: { text: string }) {
- const blocks: ReactNode[] = [];
- const lines = text.replace(/\r\n/g, "\n").split("\n");
- let i = 0;
- let key = 0;
- while (i < lines.length) {
- const line = lines[i]!;
- if (line.startsWith("```")) {
- const lang = line.slice(3).trim();
- const code: string[] = [];
- i++;
- while (i < lines.length && !lines[i]!.startsWith("```")) code.push(lines[i++]!);
- i++;
- blocks.push(
{code.join("\n")}
);
- continue;
+ // Text that changed in the last moment is still streaming: parse only its last block again, and
+ // the whole text once it has rested, so the settled rendering is always the exact one.
+ const [settled, setSettled] = useState(text);
+ useEffect(() => {
+ if (text === settled) return;
+ const timer = setTimeout(() => setSettled(text), STREAM_SETTLE_MS);
+ return () => clearTimeout(timer);
+ }, [text, settled]);
+ if (text === settled) return
{settledBlocks(text)}
;
+ return
{streamingChunks(text).map((chunk, i) => )}
;
+});
+
+/** How long text must stop changing before it is parsed whole again. */
+const STREAM_SETTLE_MS = 300;
+
+const Blocks = memo(function Blocks({ text }: { text: string }) {
+ return <>{render(text)}>;
+});
+
+function render(text: string): ReactNode[] {
+ const tree = fromMarkdown(text, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] });
+ const definitions = new Map();
+ collectDefinitions(tree, definitions);
+ return blocks(tree.children, { source: text, definitions, tight: false });
+}
+
+/** Renders by text, keeping the most recent `entries` results for texts of at most `maxChars`. */
+export class RenderCache {
+ private readonly kept = new Map();
+ constructor(private readonly entries: number, private readonly maxChars: number) {}
+ get size(): number { return this.kept.size; }
+ get(text: string, make: () => T): T {
+ if (text.length > this.maxChars) return make();
+ if (this.kept.has(text)) {
+ const hit = this.kept.get(text) as T;
+ this.kept.delete(text);
+ this.kept.set(text, hit);
+ return hit;
}
- const h = /^(#{1,3})\s+(.*)$/.exec(line);
- if (h) {
- const level = h[1]!.length;
- const content = inline(h[2]!);
- blocks.push(level === 1 ?
{content}
: level === 2 ?
{content}
:
{content}
);
- i++;
- continue;
+ const made = make();
+ this.kept.set(text, made);
+ if (this.kept.size > this.entries) this.kept.delete(this.kept.keys().next().value!);
+ return made;
+ }
+}
+
+/**
+ * Settled text, rendered once per app session: reopening a thread or switching back to one reuses
+ * the elements instead of parsing every message again. Streaming pieces are never cached, nor is
+ * very long text such as a fetched page.
+ */
+const settledRenders = new RenderCache(400, 64_000);
+function settledBlocks(text: string): ReactNode[] {
+ return settledRenders.get(text, () => render(text));
+}
+
+/**
+ * Splits text after each blank line that is outside a fenced code block, so a streaming reply
+ * re-parses only its last piece. Joining the pieces gives the text back. A piece can read slightly
+ * differently from the whole (a loose list shows as two lists) until the text settles.
+ */
+export function streamingChunks(text: string): string[] {
+ const chunks: string[] = [];
+ let start = 0;
+ let fence: { char: string; length: number } | null = null;
+ for (let at = 0; at < text.length;) {
+ const newline = text.indexOf("\n", at);
+ const end = newline < 0 ? text.length : newline + 1;
+ const line = text.slice(at, newline < 0 ? text.length : newline);
+ const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
+ if (fence) {
+ if (marker && marker[1]![0] === fence.char && marker[1]!.length >= fence.length && !marker[2]!.trim()) fence = null;
+ } else if (marker && !(marker[1]![0] === "`" && marker[2]!.includes("`"))) {
+ fence = { char: marker[1]![0]!, length: marker[1]!.length };
+ } else if (!line.trim() && at > start) {
+ chunks.push(text.slice(start, end));
+ start = end;
}
- if (/^\s*([-*]|\d+\.)\s+/.test(line)) {
- const items: ReactNode[] = [];
- const ordered = /^\s*\d+\./.test(line);
- while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i]!)) items.push(
);
- continue;
+ at = end;
+ }
+ if (start < text.length) chunks.push(text.slice(start));
+ return chunks;
+}
+
+interface Context {
+ source: string;
+ definitions: Map;
+ /** Inside a tight list item, paragraphs render as bare text. */
+ tight: boolean;
+}
+
+function collectDefinitions(node: Nodes, into: Map): void {
+ if (node.type === "definition" && !into.has(node.identifier)) into.set(node.identifier, node);
+ if ("children" in node) for (const child of node.children) collectDefinitions(child, into);
+}
+
+/** The Markdown exactly as written, for constructs Modex will not turn into elements. */
+function written(node: Nodes, ctx: Context): string {
+ const start = node.position?.start.offset;
+ const end = node.position?.end.offset;
+ return start === undefined || end === undefined ? "" : ctx.source.slice(start, end);
+}
+
+function blocks(nodes: RootContent[], ctx: Context): ReactNode[] {
+ return nodes.map((node, key) => block(node, key, ctx));
+}
+
+function block(node: RootContent, key: number, ctx: Context): ReactNode {
+ switch (node.type) {
+ case "paragraph":
+ return ctx.tight ? {inline(node.children, ctx)} :
{inline(node.children, ctx)}
;
+ case "heading": {
+ // The transcript's own titles sit above a reply's headings: `#` reads as a section, not the page.
+ const Tag = (["h2", "h3", "h4", "h5", "h6", "h6"] as const)[node.depth - 1]!;
+ return {inline(node.children, ctx)};
}
- if (!line.trim()) {
- i++;
- continue;
+ case "code":
+ return ;
+ case "blockquote":
+ return
.*two/s);
+ assert.match(html, /]*\/>.*done/s);
+ assert.match(html, /]*\/>.*todo/s);
+ assert.match(html, /soft<\/em> and gone<\/del> and code<\/code>/);
+});
+
+test("headings keep their place below the transcript's own titles, and a fence keeps its language and indentation", async () => {
+ const html = await render("# One\n## Two\n### Three\n#### Four\n\n```ts\nif (a) {\n return 1;\n}\n```");
+ assert.match(html, /
One<\/h2>
Two<\/h3>
Three<\/h4>
Four<\/h5>/);
+ assert.match(html, /
if \(a\) \{\n return 1;\n\}<\/code><\/pre>/);
+});
+
+test("a fence that is still streaming shows its code so far, and the closing fence changes nothing before it", async () => {
+ const open = await render("Here:\n\n```js\nconst a = 1;\nconst b = ");
+ assert.match(open, /
const a = 1;\nconst b = <\/code><\/pre>/);
+ const closed = await render("Here:\n\n```js\nconst a = 1;\nconst b = 2;\n```\n\nDone.");
+ assert.match(closed, /const a = 1;\nconst b = 2;<\/code><\/pre>.*?
Done\.<\/p>/s);
+});
+
+test("every prefix of a streamed reply renders, and no prefix loses or doubles its text", async () => {
+ const reply = "Fix **the `parse()` call** in [the docs](https://example.com/d).\n\n- [ ] add a test\n- ~~old~~ new\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n```py\nprint('hi')\n```\n";
+ // Each word occurs once in the reply and never inside a URL, so a reader must see it exactly as often as the prefix holds it whole.
+ const words = ["Fix", "parse", "call", "docs", "add", "test", "old", "new", "print", "hi"];
+ for (let end = 1; end <= reply.length; end++) {
+ const prefix = reply.slice(0, end);
+ const shown = visible(await render(prefix));
+ for (const word of words) {
+ const count = (s: string) => s.split(word).length - 1;
+ // A word the prefix is still in the middle of may legitimately be absent or partial.
+ const whole = count(prefix.replace(/\w+$/, ""));
+ assert.ok(count(shown) >= whole && count(shown) <= count(prefix), `prefix ${end} ${JSON.stringify(prefix.slice(-14))}: "${word}" shown ${count(shown)} times`);
+ }
+ }
+});
+
+test("Unicode passes through untouched", async () => {
+ const text = "Café ✓ — naïve 日本語 👩🏽💻 é";
+ assert.equal(visible(await render(text)), text);
+});
+
+test("raw HTML stays text: no element, attribute or handler reaches the page", async () => {
+ const html = await render('\n\nhi b\n\n