Q145: chat messages render GitHub-flavored markdown with code blocks, tables and lists

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 00:29:18 -04:00
1 parent 2a89d1e883
commit 8a06f2ff77
6 files changed
+1086 -39

No files matched your search

+2
View File
@@ -34,7 +34,9 @@
"lucide-react": "0.468.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "10.1.0",
"reicon-react": "^1.1.2",
"remark-gfm": "4.0.1",
"zod": "4.3.6"
},
"devDependencies": {
+876
View File
File diff suppressed because it is too large. Load diff
+12 -39
View File
@@ -10,6 +10,7 @@ import type {
import { formatTime } from "../types";
import { messageAttachments, senderName, type MessageContext } from "./chat-helpers";
import type { ThreadLink } from "./thread-links";
import { MessageMarkdown } from "./MessageMarkdown";
import type { ChatReplyStatus } from "../../../../shared/chat-domain";
import { t } from "../../i18n/i18n";
@@ -110,7 +111,7 @@ export function ChatMessageRow({
</div>
</form>
) : (
<div style={{ fontSize: 14, color: "var(--text-1)", lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
<div style={{ fontSize: 14, color: "var(--text-1)", lineHeight: 1.5, wordBreak: "break-word" }}>
{renderBody(message.bodyMd, messageMentions, context)}
</div>
)}
@@ -158,8 +159,6 @@ function replyStatusText(status: ChatReplyStatus, context: MessageContext): stri
}
function renderBody(body: string, mentions: ChatMentionRecord[], context: MessageContext): ReactNode {
if (mentions.length === 0) return formatInline(body, "b");
const parts: ReactNode[] = [];
const ordered = [...mentions].sort((a, b) => a.offsetStart - b.offsetStart);
let lastIndex = 0;
for (const mention of ordered) {
@@ -167,14 +166,14 @@ function renderBody(body: string, mentions: ChatMentionRecord[], context: Messag
if (mention.offsetStart < 0 || mention.offsetEnd > body.length || mention.offsetStart >= mention.offsetEnd) {
throw new Error("Message mention offset is invalid.");
}
const token = body.slice(mention.offsetStart, mention.offsetEnd);
if (!token.startsWith("@")) throw new Error("Message mention token is invalid.");
if (mention.offsetStart > lastIndex) parts.push(...formatInline(body.slice(lastIndex, mention.offsetStart), `s${lastIndex}`));
const label = mentionLabel(mention, context);
if (!body.slice(mention.offsetStart, mention.offsetEnd).startsWith("@")) throw new Error("Message mention token is invalid.");
lastIndex = mention.offsetEnd;
}
const renderMention = (index: number) => {
const mention = ordered[index];
const failed = mention.wakeState === "failed";
parts.push(
return (
<span
key={`m-${mention.messageId}-${mention.offsetStart}`}
title={failed ? t("Agent could not be reached") : undefined}
style={{
background: failed ? "rgba(255,100,80,0.1)" : "var(--accent-soft)",
@@ -185,37 +184,11 @@ function renderBody(body: string, mentions: ChatMentionRecord[], context: Messag
textDecoration: failed ? "line-through" : undefined,
}}
>
@{label}
</span>,
@{mentionLabel(mention, context)}
</span>
);
lastIndex = mention.offsetEnd;
}
if (lastIndex < body.length) parts.push(...formatInline(body.slice(lastIndex), `s${lastIndex}`));
return parts;
}
/**
* Renders the light markdown agents and people use in chat — **bold**, *italic*,
* `code`, and [text](https://link) — into React nodes. Links are restricted to
* http(s) and nothing is ever set as raw HTML, so message bodies stay inert. A
* message with no markdown comes back as a single plain string.
*/
function formatInline(text: string, keyPrefix: string): ReactNode[] {
const pattern = /(\*\*([^*\n]+)\*\*|\*([^*\n]+)\*|`([^`\n]+)`|\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\))/g;
const nodes: ReactNode[] = [];
let lastIndex = 0;
let token = 0;
for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
if (match.index > lastIndex) nodes.push(text.slice(lastIndex, match.index));
const key = `${keyPrefix}-${token++}`;
if (match[2] !== undefined) nodes.push(<strong key={key}>{match[2]}</strong>);
else if (match[3] !== undefined) nodes.push(<em key={key}>{match[3]}</em>);
else if (match[4] !== undefined) nodes.push(<code key={key} className="mono" style={{ background: "var(--panel-3)", padding: "1px 5px", borderRadius: 4, fontSize: "0.92em" }}>{match[4]}</code>);
else nodes.push(<a key={key} href={match[6]} target="_blank" rel="noreferrer" style={{ color: "var(--accent)" }}>{match[5]}</a>);
lastIndex = pattern.lastIndex;
}
if (lastIndex < text.length) nodes.push(text.slice(lastIndex));
return nodes;
};
return <MessageMarkdown body={body} mentions={ordered.map((mention) => ({ start: mention.offsetStart, end: mention.offsetEnd }))} renderMention={renderMention} />;
}
function mentionLabel(mention: ChatMentionRecord, context: MessageContext): string {
@@ -0,0 +1,110 @@
import type { ReactNode } from "react";
import Markdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
/** The slice of a hast node this file reads and builds. */
interface HastNode {
type: string;
tagName?: string;
value?: string;
properties?: Record<string, unknown>;
children?: HastNode[];
}
/** Placeholders that mark the resolved mention ranges; the random nonce keeps a body (or an entity in it) from forging one. */
function placeholders() {
const nonce = Array.from(crypto.getRandomValues(new Uint32Array(2)), (part) => part.toString(16).padStart(8, "0")).join("");
return {
make: (index: number) => `@\uE000${nonce}x${index}\uE001`,
plain: new RegExp(`@\\uE000${nonce}x(\\d+)\\uE001`, "g"),
// The parser percent-encodes the private-use characters inside a URL.
encoded: new RegExp(`@%EE%80%80${nonce}x(\\d+)%EE%80%81`, "g"),
};
}
/** Turns placeholders into `<span data-mention="i">` outside code, and back into the mention text inside code and attributes. */
function mentionSpans(tokens: string[], marks: ReturnType<typeof placeholders>) {
const token = (_match: string, index: string) => tokens[Number(index)];
const restore = (text: string) => text.replace(marks.plain, token).replace(marks.encoded, token);
const split = (value: string): HastNode[] => {
const pieces: HastNode[] = [];
let cursor = 0;
for (const match of value.matchAll(marks.plain)) {
if (match.index > cursor) pieces.push({ type: "text", value: value.slice(cursor, match.index) });
pieces.push({ type: "element", tagName: "span", properties: { dataMention: match[1] }, children: [{ type: "text", value: tokens[Number(match[1])] }] });
cursor = match.index + match[0].length;
}
if (cursor < value.length) pieces.push({ type: "text", value: value.slice(cursor) });
return pieces;
};
const walk = (node: HastNode, inCode: boolean): void => {
for (const [key, property] of Object.entries(node.properties ?? {})) {
if (typeof property === "string") node.properties![key] = restore(property);
}
if (!node.children) return;
const childInCode = inCode || node.tagName === "code" || node.tagName === "pre";
node.children = node.children.flatMap((child) => {
if (child.type !== "text" || child.value === undefined) {
walk(child, childInCode);
return [child];
}
return childInCode ? [{ ...child, value: restore(child.value) }] : split(child.value);
});
};
return () => (tree: HastNode) => walk(tree, false);
}
/** The app window opens no new windows, so a link goes to the system browser through the main process. */
function openLink(href: string): void {
void window.agentCompany.openExternalUrl(href).then((result) => {
if (!result.ok) console.error(`[chat] could not open ${href}: ${result.error.message}`);
});
}
const COMPONENTS: Components = {
a: ({ href, children }) => (
<a
href={href}
rel="noreferrer"
style={{ color: "var(--accent)" }}
onClick={(event) => {
event.preventDefault();
if (href) openLink(href);
}}
>
{children}
</a>
),
};
/** A chat message body as GitHub-flavored markdown; raw HTML and images are not rendered, and mentions become chips. */
export function MessageMarkdown({ body, mentions, renderMention }: {
body: string;
/** Mention ranges in `body`, sorted and non-overlapping. */
mentions: Array<{ start: number; end: number }>;
renderMention: (index: number) => ReactNode;
}) {
const components: Components = {
...COMPONENTS,
span: ({ node: _node, children, ...props }) => {
const index = (props as Record<string, unknown>)["data-mention"];
return typeof index === "string" ? renderMention(Number(index)) : <span {...props}>{children}</span>;
},
};
const marks = placeholders();
let source = "";
let cursor = 0;
mentions.forEach((mention, index) => {
source += body.slice(cursor, mention.start) + marks.make(index);
cursor = mention.end;
});
source += body.slice(cursor);
const tokens = mentions.map((mention) => body.slice(mention.start, mention.end));
return (
<div className="md-body">
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[mentionSpans(tokens, marks)]} skipHtml disallowedElements={["img"]} components={components}>
{source}
</Markdown>
</div>
);
}
+15
View File
@@ -328,3 +328,18 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
z-index: 2;
}
.pane-resize-handle:hover { background: color-mix(in srgb, var(--accent) 35%, transparent); }
/* Chat message markdown: soft line breaks stay visible, code and tables scroll instead of overflowing. */
.md-body > :first-child { margin-top: 0; }
.md-body > :last-child { margin-bottom: 0; }
.md-body p { margin: 0 0 6px; white-space: pre-wrap; }
.md-body ul, .md-body ol { margin: 0 0 6px; padding-left: 22px; }
.md-body li > ul, .md-body li > ol { margin: 2px 0; }
.md-body h1, .md-body h2, .md-body h3 { font-size: 15px; font-weight: 600; margin: 8px 0 4px; }
.md-body blockquote { margin: 0 0 6px; padding-left: 10px; border-left: 3px solid var(--border-strong); color: var(--text-2); }
.md-body code { font-family: var(--font-mono); background: var(--panel-3); padding: 1px 5px; border-radius: 4px; font-size: 0.92em; }
.md-body pre { margin: 0 0 6px; padding: 10px 12px; background: var(--panel-3); border-radius: 8px; overflow-x: auto; }
.md-body pre code { background: transparent; padding: 0; white-space: pre; }
.md-body table { border-collapse: collapse; margin: 0 0 6px; display: block; overflow-x: auto; max-width: 100%; }
.md-body th, .md-body td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; }
.md-body th { background: var(--panel-2); font-weight: 600; }
+71
View File
@@ -0,0 +1,71 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test } from "vitest";
import { MessageMarkdown } from "../../src/renderer/src/pages/chat/MessageMarkdown";
const render = (body: string, mentions: Array<{ start: number; end: number }> = []) => renderToStaticMarkup(createElement(MessageMarkdown, {
body,
mentions,
renderMention: (index: number) => createElement("b", { "data-chip": index }, "CHIP"),
}));
describe("message markdown", () => {
test("renders code blocks, tables and nested lists instead of raw markup", () => {
const html = render("Plan:\n\n```ts\nconst a = 1;\n```\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n- one\n - nested\n- two");
expect(html).toContain("<pre");
expect(html).toContain("const a = 1;");
expect(html).toContain("<table");
expect(html).toContain("<td>2</td>");
expect(html).toMatch(/<li>one\s*<ul[^>]*>\s*<li>nested<\/li>/);
expect(html).not.toContain("```");
});
test("keeps line breaks, drops raw HTML and images, and opens links outside", () => {
const html = render("line one\nline two <script>alert(1)</script> ![x](https://e.com/x.png) [site](https://e.com)");
expect(html).toContain("line one\nline two");
expect(html).not.toContain("<script");
expect(html).not.toContain("<img");
expect(html).toContain('href="https://e.com"');
});
test("turns mention ranges into chips, but not inside code", () => {
const body = "Hi @Dana see `@Dana` and **@Dana**";
const at = (from: number) => ({ start: body.indexOf("@Dana", from), end: body.indexOf("@Dana", from) + 5 });
const first = at(0);
const inCode = at(first.end);
const bold = at(inCode.end);
const html = render(body, [first, inCode, bold]);
expect(html.match(/CHIP/g)).toHaveLength(2);
expect(html).toContain("<code");
expect(html).toMatch(/<code[^>]*>@Dana<\/code>/);
expect(html).toMatch(/<strong><b data-chip="2">CHIP<\/b><\/strong>/);
});
test("finds mentions in text that entities or escapes changed", () => {
for (const body of ["Use &amp; @Dana now", "Stars \\* and @Dana"]) {
const start = body.indexOf("@Dana");
expect(render(body, [{ start, end: start + 5 }]).match(/CHIP/g)).toHaveLength(1);
}
});
test("chips the mention the server resolved, not a look-alike copy before it", () => {
for (const body of ["&#64;Dana and @Dana", "\\@Dana and @Dana", "x@Dana and @Dana"]) {
const start = body.lastIndexOf("@Dana");
const html = render(body, [{ start, end: start + 5 }]);
expect(html.match(/CHIP/g)).toHaveLength(1);
expect(html).toMatch(/@Dana and <b data-chip="0">CHIP<\/b>/);
}
});
test("a mention inside a link URL stays the URL's text", () => {
const body = "[profile](https://e.com/@Dana)";
const start = body.indexOf("@Dana");
expect(render(body, [{ start, end: start + 5 }])).toContain('href="https://e.com/@Dana"');
});
test("an entity in the body cannot forge a mention chip", () => {
for (const forged of ["&#xE000;0&#xE001;", "@&#xE000;0&#xE001;"]) {
expect(render(`Hello ${forged}`).match(/CHIP/g)).toBeNull();
}
});
});