Skip to content

Commit a4039b2

Browse files
authored
Render transcript Markdown with CommonMark and GitHub's extensions (CF-4 parser slice) (#283)
* Render transcript Markdown with CommonMark and GitHub's extensions Markdown.tsx's hand-written subset becomes micromark with its GFM extension (mdast-util-from-markdown 2.0.3, micromark-extension-gfm 3.0.0, mdast-util-gfm 3.1.0, pinned and bundled into the renderer). The tree renders straight to React elements: tables, task lists (inert boxes), block quotes, nested lists, strikethrough, footnotes, reference links and language-tagged fences. Raw HTML stays text, only http(s) links are links, other targets stay as written, and an image becomes a link to it instead of being fetched. Parsing every delta again would cost a long reply about 4 ms a render here, five times the old subset. While text keeps changing, only its last block (split at blank lines outside fences) is parsed again, about 0.2 ms a delta; the whole text is parsed once it rests for 300 ms. Settled renders are cached, so reopening a thread does not parse it again. First open of a 200-message, 239 KB thread: about 160 ms, against 40 ms before. Part of #277 (parser slice); semantic turn rendering waits for #274. Coven-Harness: Claude Code / claude-opus-5-5 * Record full Markdown rendering in status Coven-Harness: Claude Code / claude-opus-5-5 * Bound the settled Markdown cache by entries and text size The transcript's Markdown also renders fetched pages, which can be large. RenderCache keeps the 400 most recent settled renders of text up to 64,000 characters; longer text renders each time and is never kept. Coven-Harness: Claude Code / claude-opus-5-5 * Give transcript code blocks a language label and Copy CodeBlock (components/transcript/) heads a fenced block with its language ("text" when it has none) and a Copy button. Copy sends the code through main's clipboard:write and does nothing else: no tool, approval or turn starts. A status line tells screen readers it copied, or that copying failed. The e2e copies a block and checks the clipboard holds exactly its code while the thread stays idle. Part of #277. Coven-Harness: Claude Code / claude-opus-5-5 * Read rendered test markup without a regex tag stripper CodeQL flagged the Markdown test's visible() helper as incomplete multi-character sanitization: it removed tags with a regex and then decoded entities. It only reads React's static markup in a test, but the helper now skips tags character by character and decodes each entity once, which is also what a reader sees. Coven-Harness: Claude Code / claude-opus-5-5 * Click Copy again when a late layout change moves the code block CI run 39112253838 failed the code-block Copy e2e once: Copied never showed after a 42 ms click, while local runs and two other CI runs passed. The likely cause: the follow-up suggestion arrives after the turn ends, grows the composer, and the transcript moves under the pointer. The test now clicks until Copied shows. A missed click copies nothing, so this cannot hide a wrong copy, and the clipboard and nothing-started assertions are unchanged. 9 of 9 local repeats passed. Coven-Harness: Claude Code / claude-opus-5-5 * Keep a selection and focus in a reply when it finishes streaming While a reply streamed, Markdown rendered it as memoized pieces; once the text rested it switched to plain elements from one full parse. The children's component type changed, so React unmounted every piece and mounted new DOM: text the reader had selected in the reply, keyboard focus on a control in it, and a code block's Copied state were lost the moment it finished (e2e/transcript-continuity.spec.ts, red on 74833f7: the selection read "" and Copy lost focus). Both ways now render one Piece per streaming piece. The settled render parses the whole text and groups its top-level blocks back into those pieces by source offset, so settling updates each piece where it stands. Streaming pieces are kept per component instance by text, so an unchanged piece is still not parsed again. Part of #277. Coven-Harness: Claude Code / claude-opus-5-5 * Record that a finishing reply keeps its selection and focus Coven-Harness: Claude Code / claude-opus-5-5 * Keep table columns readable at the 900x600 minimum At 900x600 with the Review panel open, the transcript is 265 px wide. A table with an unbreakable path took 413 px for that column and squeezed the others to a word: Detail measured 58 px and its row 1,009 px tall. Cells now keep at least 10em and let a long path or URL wrap inside, so the table scrolls sideways in its own box (the row is 344 px; nothing is cut to one word per line). conversation-rendering.spec.ts checks, in Graphite, Jev and OpenCoven at 900x600: no sideways page scroll, a wide table and a long code line scroll in their own boxes, every cell is at least about ten characters wide, and Copy stays visible. It attaches a screenshot per theme (MODEX_SCREENSHOTS keeps them). Part of #277. Coven-Harness: Claude Code / claude-opus-5-5 * Leave the settle-remount fix to #295 and keep the narrow-table fix #295 (CF-4 completion, another session) is stacked on this branch at 74833f7 and fixes the same remount-at-settle defect as 083b8fb, its own way, with its own continuity tests and theme captures. Keeping both would conflict in Markdown.tsx, conversation-rendering.spec.ts and status.md and make #295's evidence stale. So this restores Markdown.tsx, markdown-render.test.ts and conversation-rendering.spec.ts to 74833f7, drops transcript-continuity.spec.ts, and reverts the status clause. #295 does not touch styles.css, so the 900x600 table fix stays: cells keep at least 10em and wrap long tokens. Its theme check moves to its own file, e2e/markdown-narrow.spec.ts, so it cannot conflict with #295. Coven-Harness: Claude Code / claude-opus-5-5
1 parent 7ec5f14 commit a4039b2

9 files changed

Lines changed: 1151 additions & 49 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { test, expect, type ElectronApplication, type Page } from "@playwright/test";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { currentRow, items, launch, seedHome, tid } from "./support";
5+
6+
/**
7+
* How a reply reads in the transcript (#277). This part covers its Markdown: GitHub-flavoured blocks
8+
* render as elements, raw HTML stays text, and nothing in a reply runs, fetches or navigates.
9+
*/
10+
let app: ElectronApplication;
11+
let page: Page;
12+
let home: string;
13+
let repo: string;
14+
const dialogs: string[] = [];
15+
16+
const REPLY = [
17+
"## Result",
18+
"",
19+
"| File | Lines |",
20+
"| :--- | ---: |",
21+
"| a.ts | 12 |",
22+
"",
23+
"> Run the suite before pushing.",
24+
"",
25+
"- [x] parser",
26+
"- [ ] renderer",
27+
" 1. nested step",
28+
"",
29+
"```ts",
30+
"export const answer = 42;",
31+
"```",
32+
"",
33+
'Inert: <img src="https://example.com/x.png" onerror="alert(1)"> and [a script](javascript:alert(2)) and ![chart](https://example.com/c.png).',
34+
].join("\n");
35+
36+
test.beforeAll(async () => {
37+
({ home, repo } = seedHome());
38+
({ app, page } = await launch(home));
39+
page.on("dialog", (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss(); });
40+
const script = path.join(home, "rendering-script.json");
41+
fs.writeFileSync(script, JSON.stringify([{ content: REPLY }]));
42+
await page.evaluate((mock_script) => window.modex!.invoke("settings:update", { mock_script }), script);
43+
await page.keyboard.press("Meta+n");
44+
await expect(tid(page, "composer-input")).toBeFocused();
45+
await tid(page, "composer-input").fill("summarize the change");
46+
await page.keyboard.press("Meta+Enter");
47+
await expect(tid(page, "thread-view")).toBeVisible({ timeout: 15_000 });
48+
await expect(items(page, "assistant")).toHaveCount(1);
49+
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
50+
});
51+
52+
test.afterAll(async () => {
53+
await app?.close();
54+
fs.rmSync(home, { recursive: true, force: true });
55+
fs.rmSync(repo, { recursive: true, force: true });
56+
});
57+
58+
test("a reply's tables, quotes, task lists and code render as elements", async () => {
59+
const answer = items(page, "assistant").first();
60+
await expect(answer.getByRole("heading", { name: "Result" })).toBeVisible();
61+
const table = answer.locator("table");
62+
await expect(table.getByRole("columnheader")).toHaveText(["File", "Lines"]);
63+
await expect(table.getByRole("cell")).toHaveText(["a.ts", "12"]);
64+
await expect(answer.locator("blockquote")).toHaveText("Run the suite before pushing.");
65+
const boxes = answer.getByRole("checkbox");
66+
await expect(boxes).toHaveCount(2);
67+
await expect(boxes.nth(0)).toBeChecked();
68+
await expect(boxes.nth(1)).not.toBeChecked();
69+
await expect(boxes.nth(0)).toBeDisabled();
70+
await expect(answer.locator("li ol li")).toHaveText("nested step");
71+
await expect(answer.locator('pre.code[data-lang="ts"]')).toHaveText("export const answer = 42;");
72+
});
73+
74+
test("raw HTML, script links and images in a reply stay inert", async () => {
75+
const answer = items(page, "assistant").first();
76+
await expect(answer).toContainText('<img src="https://example.com/x.png" onerror="alert(1)">');
77+
await expect(answer).toContainText("[a script](javascript:alert(2))");
78+
await expect(answer.locator("img")).toHaveCount(0);
79+
await expect(answer.locator('a[href^="javascript:"]')).toHaveCount(0);
80+
await expect(answer.getByRole("link", { name: "Image: chart" })).toHaveAttribute("href", "https://example.com/c.png");
81+
expect(dialogs).toEqual([]);
82+
});
83+
84+
test("Copy on a code block puts exactly its code on the clipboard and starts nothing", async () => {
85+
const answer = items(page, "assistant").first();
86+
const block = tid(answer, "code-block");
87+
await expect(block).toContainText("ts");
88+
// A follow-up suggestion can arrive after the turn ends and grow the composer, which moves the
89+
// transcript under the pointer mid-click: the likely cause of the one CI run (39112253838) where
90+
// Copied never showed after a 42 ms click. A missed click copies nothing, so clicking again until
91+
// Copied shows cannot hide a wrong copy.
92+
const copy = tid(block, "code-copy");
93+
await expect(async () => {
94+
await copy.click();
95+
await expect(copy).toHaveText("Copied", { timeout: 1000 });
96+
}).toPass({ timeout: 15_000 });
97+
expect(await app.evaluate(({ clipboard }) => clipboard.readText())).toBe("export const answer = 42;");
98+
await expect(items(page, "tool")).toHaveCount(0);
99+
await expect(items(page, "approval")).toHaveCount(0);
100+
await expect(items(page, "user")).toHaveCount(1);
101+
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
102+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { test, expect, type ElectronApplication, type Page } from "@playwright/test";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { currentRow, items, launch, resizeWindow, seedHome, tid } from "./support";
5+
6+
/**
7+
* Markdown at the 900x600 minimum (#277): with the Review panel open the transcript is about 265 px
8+
* wide. A wide table and a long code line scroll inside their own boxes, and no table column is
9+
* squeezed to a word, in every theme.
10+
*/
11+
let app: ElectronApplication;
12+
let page: Page;
13+
let home: string;
14+
let repo: string;
15+
16+
test.beforeAll(async () => {
17+
({ home, repo } = seedHome());
18+
({ app, page } = await launch(home));
19+
await page.keyboard.press("Meta+n");
20+
await expect(tid(page, "composer-input")).toBeFocused();
21+
});
22+
23+
test.afterAll(async () => {
24+
await app?.close();
25+
fs.rmSync(home, { recursive: true, force: true });
26+
fs.rmSync(repo, { recursive: true, force: true });
27+
});
28+
29+
const WIDE = [
30+
"| Path | Change | Detail |",
31+
"| --- | --- | --- |",
32+
"| apps/desktop/src/renderer/components/transcript/CodeBlock.tsx | added | " + "a long cell that keeps going ".repeat(8) + "|",
33+
"",
34+
"```ts",
35+
"export const aVeryLongLineOfCode = [" + Array.from({ length: 30 }, (_, i) => `"item-${i}"`).join(", ") + "];",
36+
"```",
37+
].join("\n");
38+
39+
test("a reply's tables and code stay readable in every theme at the 900×600 minimum", async ({}, testInfo) => {
40+
const script = path.join(home, "wide-script.json");
41+
fs.writeFileSync(script, JSON.stringify([{ content: WIDE }]));
42+
await page.evaluate((mock_script) => window.modex!.invoke("settings:update", { mock_script }), script);
43+
const replies = await items(page, "assistant").count();
44+
await tid(page, "composer-input").fill("show the wide table");
45+
await page.keyboard.press("Meta+Enter");
46+
// Wait for this reply itself, then for the turn to end: the thread was already idle before it.
47+
await expect(items(page, "assistant")).toHaveCount(replies + 1, { timeout: 20_000 });
48+
await expect(items(page, "assistant").last()).toContainText("aVeryLongLineOfCode");
49+
await expect(currentRow(page)).toHaveAttribute("data-status", "idle", { timeout: 20_000 });
50+
const answer = items(page, "assistant").last();
51+
await expect(answer.locator("table")).toBeVisible();
52+
await resizeWindow(app, page, 900, 600);
53+
for (const theme of ["graphite", "jev", "coven"]) {
54+
await page.evaluate((value) => window.modex!.invoke("settings:update", { theme: value }), theme);
55+
await answer.scrollIntoViewIfNeeded();
56+
// The page never scrolls sideways: a wide table and a long code line scroll inside their own boxes.
57+
expect(await tid(page, "transcript").evaluate((el) => el.scrollWidth - el.clientWidth), `${theme}: no sideways page scroll`).toBeLessThanOrEqual(0);
58+
expect(await answer.locator(".md-table").evaluate((el) => el.scrollWidth > el.clientWidth), `${theme}: the table scrolls in its box`).toBe(true);
59+
// No column is squeezed to a word: before cells had a minimum width, Detail measured 58 px and its row 1,009 px tall.
60+
const narrowest = await answer.locator("td").evaluateAll((cells) => Math.min(...cells.map((c) => c.getBoundingClientRect().width / parseFloat(getComputedStyle(c).fontSize))));
61+
expect(narrowest, `${theme}: every cell is at least about ten characters wide`).toBeGreaterThanOrEqual(9.5);
62+
expect(await answer.locator("pre.code").evaluate((el) => el.scrollWidth > el.clientWidth), `${theme}: the code scrolls in its box`).toBe(true);
63+
await expect(tid(answer, "code-copy")).toBeVisible();
64+
await answer.locator("table").evaluate((table) => table.scrollIntoView({ block: "start" }));
65+
const shot = await page.screenshot();
66+
await testInfo.attach(`markdown-${theme}-900x600`, { body: shot, contentType: "image/png" });
67+
// MODEX_SCREENSHOTS=<dir> keeps them for review evidence.
68+
if (process.env.MODEX_SCREENSHOTS) fs.writeFileSync(path.join(process.env.MODEX_SCREENSHOTS, `markdown-${theme}-900x600.png`), shot);
69+
}
70+
});

‎apps/desktop/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,16 @@
3838
},
3939
"devDependencies": {
4040
"@playwright/test": "^1.63.0",
41+
"@types/mdast": "4.0.4",
4142
"@types/qrcode": "^1.5.6",
4243
"@types/react": "^19.0.0",
4344
"@types/react-dom": "^19.0.0",
4445
"@vitejs/plugin-react": "^6.1.1",
4546
"@xterm/addon-fit": "0.11.0",
4647
"@xterm/xterm": "6.0.0",
48+
"mdast-util-from-markdown": "2.0.3",
49+
"mdast-util-gfm": "3.1.0",
50+
"micromark-extension-gfm": "3.0.0",
4751
"electron": "^44.4.5",
4852
"electron-builder": "26.15.3",
4953
"react": "^19.3.0",

0 commit comments

Comments
 (0)