From 25946d84c8a6cf91b72f857dd8e0a412fa750a74 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 18 Oct 2026 01:52:38 -0500 Subject: [PATCH 01/10] 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 --- .../e2e/conversation-rendering.spec.ts | 82 +++ apps/desktop/package.json | 4 + .../src/renderer/components/Markdown.tsx | 253 +++++++-- apps/desktop/src/renderer/styles.css | 14 +- apps/desktop/test/markdown-render.test.ts | 132 +++++ pnpm-lock.yaml | 527 ++++++++++++++++++ 6 files changed, 963 insertions(+), 49 deletions(-) create mode 100644 apps/desktop/e2e/conversation-rendering.spec.ts create mode 100644 apps/desktop/test/markdown-render.test.ts diff --git a/apps/desktop/e2e/conversation-rendering.spec.ts b/apps/desktop/e2e/conversation-rendering.spec.ts new file mode 100644 index 00000000..c39d3f64 --- /dev/null +++ b/apps/desktop/e2e/conversation-rendering.spec.ts @@ -0,0 +1,82 @@ +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 ![chart](https://example.com/c.png).', +].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([]); +}); 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..8c0e8441 100644 --- a/apps/desktop/src/renderer/components/Markdown.tsx +++ b/apps/desktop/src/renderer/components/Markdown.tsx @@ -1,59 +1,216 @@ -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"; -/** 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 }); +} + +/** + * 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. + */ +const SETTLED_CACHE = 400; +const settledCache = new Map(); +function settledBlocks(text: string): ReactNode[] { + const hit = settledCache.get(text); + if (hit) { + settledCache.delete(text); + settledCache.set(text, hit); + return hit; + } + const rendered = render(text); + settledCache.set(text, rendered); + if (settledCache.size > SETTLED_CACHE) settledCache.delete(settledCache.keys().next().value!); + return rendered; +} + +/** + * 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; + } + 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)}; } - 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; + case "code": + return
{node.value}
; + case "blockquote": + return
{blocks(node.children, { ...ctx, tight: false })}
; + case "list": { + const items = node.children.map((item, i) => listItem(item, i, { ...ctx, tight: !node.spread && !item.spread })); + return node.ordered ?
    {items}
:
    {items}
; } - 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(
  • {inline(lines[i]!.replace(/^\s*([-*]|\d+\.)\s+/, ""))}
  • ), i++; - blocks.push(ordered ?
      {items}
    :
      {items}
    ); - continue; + case "thematicBreak": + return
    ; + case "table": { + const [head, ...body] = node.children; + const align = node.align ?? []; + const row = (r: TableRow, i: number, Cell: "th" | "td") => ( + {r.children.map((cell, c) => {inline(cell.children, ctx)})} + ); + return ( +
    + + {head && {row(head, 0, "th")}} + {body.length > 0 && {body.map((r, i) => row(r, i, "td"))}} +
    +
    + ); + } + case "html": + // Raw HTML is shown, never interpreted. + return

    {node.value}

    ; + case "footnoteDefinition": + return
    {node.label ?? node.identifier} {blocks(node.children, { ...ctx, tight: true })}
    ; + case "definition": + return null; + default: + return "children" in node ?
    {blocks((node as Parents).children as RootContent[], ctx)}
    : null; + } +} + +function listItem(item: ListItem, key: number, ctx: Context): ReactNode { + const children = blocks(item.children, ctx); + if (typeof item.checked !== "boolean") return
  • {children}
  • ; + // A task list's boxes are a record of the text, not controls: they cannot be ticked. + return
  • {children}
  • ; +} + +function inline(nodes: PhrasingContent[], ctx: Context): ReactNode[] { + return nodes.map((node, key) => phrase(node, key, ctx)); +} + +function phrase(node: PhrasingContent, key: number, ctx: Context): ReactNode { + switch (node.type) { + case "text": + return node.value; + case "emphasis": + return {inline(node.children, ctx)}; + case "strong": + return {inline(node.children, ctx)}; + case "delete": + return {inline(node.children, ctx)}; + case "inlineCode": + return {node.value}; + case "break": + return
    ; + case "html": + return node.value; + case "link": + return link(node.url, inline(node.children, ctx), node, key, ctx); + case "linkReference": { + const target = ctx.definitions.get(node.identifier); + return target ? link(target.url, inline(node.children, ctx), node, key, ctx) : written(node, ctx); } - if (!line.trim()) { - i++; - continue; + case "image": + return image(node.url, node.alt, node, key, ctx); + case "imageReference": { + const target = ctx.definitions.get(node.identifier); + return target ? image(target.url, node.alt, node, key, ctx) : written(node, ctx); } - const para: string[] = []; - while (i < lines.length && lines[i]!.trim() && !lines[i]!.startsWith("```") && !/^(#{1,3})\s/.test(lines[i]!) && !/^\s*([-*]|\d+\.)\s+/.test(lines[i]!)) para.push(lines[i++]!); - blocks.push(

    {inline(para.join(" "))}

    ); + case "footnoteReference": + return [{node.label ?? node.identifier}]; + default: + return written(node, ctx); } - return
    {blocks}
    ; -}); +} -function inline(s: string): ReactNode[] { - return nodes(tokenizeInline(s)); +/** Links open in the system browser: main denies the new window and hands http(s) URLs to the OS. Other targets stay as written. */ +function link(url: string, children: ReactNode, node: Nodes, key: number, ctx: Context): ReactNode { + const href = safeHref(url); + if (!href) return written(node, ctx); + return {children}; } -/** Links open in the system browser: main denies the new window and hands http(s) URLs to the OS. */ -function nodes(tokens: InlineToken[]): ReactNode[] { - return tokens.map((t, k) => - t.type === "text" ? t.text - : t.type === "code" ? {t.text} - : t.type === "bold" ? {nodes(t.children)} - : {nodes(t.children)}, - ); +/** An image is a link to it, named by its alt text: nothing is fetched while reading. */ +function image(url: string, alt: string | null | undefined, node: Nodes, key: number, ctx: Context): ReactNode { + const href = safeHref(url); + if (!href) return written(node, ctx); + return {alt ? `Image: ${alt}` : "Image"}; } diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 74bbae3e..fe2691fb 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -530,8 +530,20 @@ a.thread-pr:hover { text-decoration: underline; } .msg.user { display: flex; flex-direction: column; align-items: flex-end; } .msg.user .bubble { max-width: 70%; padding: 8px 16px; border: 1px solid var(--bubble-border); border-radius: 12px; background: var(--bg-user-bubble); white-space: pre-wrap; overflow-wrap: anywhere; } .md p { margin: 0 0 12px; } -.md h2, .md h3, .md h4 { margin: 18px 0 8px; font-weight: var(--weight-semibold); } +.md h2, .md h3, .md h4, .md h5, .md h6 { margin: 18px 0 8px; font-weight: var(--weight-semibold); } +.md h5, .md h6 { font-size: var(--text-md); } .md ul, .md ol { margin: 0 0 12px; padding-left: 28px; } +.md li > ul, .md li > ol { margin-bottom: 0; } +.md li.task { list-style: none; } +.md li.task > input { margin: 0 6px 0 -20px; vertical-align: -1px; } +.md blockquote { margin: 0 0 12px; padding: 0 0 0 12px; border-left: 3px solid var(--border-strong); color: var(--text-3); } +.md hr { margin: 16px 0; border: 0; border-top: 1px solid var(--border-pane); } +.md .md-table { margin: 0 0 12px; overflow-x: auto; } +.md table { border-collapse: collapse; font-size: var(--text-sm); } +.md th, .md td { padding: 5px 10px; border: 1px solid var(--border-pane); text-align: left; vertical-align: top; } +.md th { font-weight: var(--weight-semibold); background: var(--bg-sunken); } +.md .footnote { margin: 0 0 6px; color: var(--text-3); font-size: var(--text-sm); } +.md sup { font-size: 0.75em; } .md strong { font-weight: var(--weight-semibold); } .md code { padding: 1px 5px; border-radius: 5px; background: var(--bg-row-selected); font-size: 12.5px; } .md pre.code { margin: 8px 0 14px; padding: 12px 14px; overflow: auto; border: 1px solid var(--border-pane); border-radius: var(--radius-md); background: var(--bg-sunken); } diff --git a/apps/desktop/test/markdown-render.test.ts b/apps/desktop/test/markdown-render.test.ts new file mode 100644 index 00000000..e2821214 --- /dev/null +++ b/apps/desktop/test/markdown-render.test.ts @@ -0,0 +1,132 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import { createElement, type ComponentType } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import react from "@vitejs/plugin-react"; +import { createServer, type ViteDevServer } from "vite"; + +// The transcript's Markdown (#277): CommonMark with GitHub's extensions, rendered as React elements only. +const appDir = fileURLToPath(new URL("../../", import.meta.url)); +let server: ViteDevServer | undefined; +let Markdown: ComponentType<{ text: string }>; + +async function render(text: string): Promise { + if (!server) { + server = await createServer({ configFile: false, root: appDir, plugins: [react()], logLevel: "error", appType: "custom", server: { middlewareMode: true, hmr: false, ws: false } }); + ({ Markdown } = await server.ssrLoadModule("/src/renderer/components/Markdown.tsx") as { Markdown: ComponentType<{ text: string }> }); + } + return renderToStaticMarkup(createElement(Markdown, { text })); +} + +/** The visible text, with tags removed and the few entities React writes decoded. */ +function visible(html: string): string { + return html.replace(/<[^>]+>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&"); +} + +test.after(async () => { await server?.close(); }); + +test("tables, blockquotes, nested and task lists, emphasis and strikethrough render as their elements", async () => { + const html = await render([ + "| File | Lines |", "| :--- | ---: |", "| a.ts | 12 |", "| b.ts | 3 |", "", + "> Quoted **advice**", "", + "- one", " 1. inner first", " 2. inner second", "- two", "", + "- [x] done", "- [ ] todo", "", + "*soft* and ~~gone~~ and `code`", + ].join("\n")); + assert.match(html, /.*.*]*>File<\/th>.*]*>Lines<\/th>.*.*]*>a\.ts<\/td>.*]*>12<\/td>/s); + assert.match(html, /
    ]*>Lines<\/th>/); + assert.match(html, /
    .*Quoted advice<\/strong>.*<\/blockquote>/s); + assert.match(html, /