Render light markdown in chat messages

Bodies were printed verbatim, so an agent's **bold**, `code`, or
[link](https://...) showed as literal punctuation — the opposite of
people talking. Render the inline markdown agents and humans actually
use in chat into React nodes, composed around the existing @mention
highlighting.

Links are restricted to http(s) and nothing is ever set as raw HTML
(no dangerouslySetInnerHTML), so a message body can never inject markup
or a javascript: URL. A plain message still comes back as one string.
This commit is contained in:
ZacharyZhang-NY committed 2026-06-26 18:00:10 -04:00
1 parent 287c072c9f
commit 6367512052
1 file changed
+27 -3
@@ -132,7 +132,7 @@ export function ChatMessageRow({
}
function renderBody(body: string, mentions: ChatMentionRecord[], context: MessageContext): ReactNode {
if (mentions.length === 0) return body;
if (mentions.length === 0) return formatInline(body, "b");
const parts: ReactNode[] = [];
const ordered = [...mentions].sort((a, b) => a.offsetStart - b.offsetStart);
let lastIndex = 0;
@@ -143,7 +143,7 @@ function renderBody(body: string, mentions: ChatMentionRecord[], context: Messag
}
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(body.slice(lastIndex, mention.offsetStart));
if (mention.offsetStart > lastIndex) parts.push(...formatInline(body.slice(lastIndex, mention.offsetStart), `s${lastIndex}`));
const label = mentionLabel(mention, context);
parts.push(
<span
@@ -155,10 +155,34 @@ function renderBody(body: string, mentions: ChatMentionRecord[], context: Messag
);
lastIndex = mention.offsetEnd;
}
if (lastIndex < body.length) parts.push(body.slice(lastIndex));
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;
}
function mentionLabel(mention: ChatMentionRecord, context: MessageContext): string {
if (mention.targetType === "employee") {
const employee = context.employees.find((item) => item.id === mention.targetId);