fix(markdown): preserve user code block characters (#1750)

Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
This commit is contained in:
bashrusakh
2026-06-24 18:33:50 +03:00
committed by GitHub
co-authored by Leonid Skorobogatyy
parent 8c551c40aa
commit 569342b3c2
3 changed files with 112 additions and 42 deletions
@@ -10,11 +10,10 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { getDirectoryForFilePath } from '@/lib/path-utils';
import { useI18n } from '@/lib/i18n';
import {
buildAgentHref,
buildAgentMentionUrl,
buildSkillHref,
parseSkillHref,
} from '@/lib/messages/inlineMessageLinks';
import { prepareUserMarkdownContent, SKILL_TOKEN_PATTERN } from './userTextPartContent';
type PartWithText = Part & { text?: string; content?: string; value?: string };
@@ -25,31 +24,10 @@ type UserTextPartProps = {
agentMention?: AgentMentionInfo;
};
const SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g;
const escapeHtml = (text: string): string => {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
};
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
return mode === 'markdown' ? 'markdown' : 'plain';
};
// In Markdown a single "\n" is a soft break (rendered as a space). Users type plain
// text where each newline is meant literally, so convert soft breaks into hard breaks
// (two trailing spaces) outside of fenced code blocks, where newlines are already literal.
const applyHardLineBreaks = (markdown: string): string => {
return markdown
.split(/(```[\s\S]*?```|~~~[\s\S]*?~~~)/g)
.map((segment, index) => (index % 2 === 1 ? segment : segment.replace(/ *\n/g, ' \n')))
.join('');
};
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
const partWithText = part as PartWithText;
const rawText = partWithText.text;
@@ -145,26 +123,11 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
}, []);
const processedMarkdownContent = React.useMemo(() => {
let content = textContent;
// Step 1: First escape HTML to protect against XSS and ensure HTML tags display as text
content = escapeHtml(content);
// Step 2: Insert agent mention links with an internal href so markdown renders them as mentions, not external links.
if (agentMention?.token && content.includes(agentMention.token)) {
const mentionMarkdown = `[${agentMention.token}](${buildAgentHref(agentMention.name)})`;
content = content.replace(agentMention.token, mentionMarkdown);
}
content = content.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, skillName: string) => {
if (!skillByName.has(skillName)) return match;
return `${prefix}[/${skillName}](${buildSkillHref(skillName)})`;
return prepareUserMarkdownContent({
textContent,
agentMention,
skillNames: new Set(skillByName.keys()),
});
// Step 4: Preserve user newlines (markdown soft breaks would otherwise collapse to spaces)
content = applyHardLineBreaks(content);
return content;
}, [agentMention, skillByName, textContent]);
const plainTextContent = React.useMemo(() => {