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:
1 parent
2a89d1e883
commit
8a06f2ff77
6 files changed
+1086
-39
No files matched your search
@@ -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": {
|
||||
|
||||
Generated
+876
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>  [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 & @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 ["@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 ["0", "@0"]) {
|
||||
expect(render(`Hello ${forged}`).match(/CHIP/g)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user