// ==================================================================================
// MARKDOWN TEXT COMPONENT
// Renders markdown text with proper formatting
// ==================================================================================
const MarkdownText = ({ text, isUserMessage }) => {
const renderMarkdown = (text) => {
if (!text) {
return '';
}
// Collapse 3+ newlines to 2 so AI over-spacing doesn't create huge gaps
text = text.replace(/\n{3,}/g, '\n\n');
// Fix common "numbered list got squished into one line" cases (tickets)
if (text.includes('Ticket ID:')) {
text = text.replace(/(? {
const token = `MDLINKTOKEN${linkTokens.length}`;
linkTokens.push({ label, url });
return token;
});
// 2) Tokenize HTML anchors if the model ever outputs them
text = text.replace(/]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, (match, url, label) => {
const token = `MDLINKTOKEN${linkTokens.length}`;
linkTokens.push({ label, url });
return token;
});
// 3) Tokenize bare URLs so underscores don't get parsed as italics
text = text.replace(/(https?:\/\/[^\s<>"']+)/g, (match, url) => {
const token = `MDURLTOKEN${urlTokens.length}`;
urlTokens.push({ url });
return token;
});
// 4) Escape any raw HTML
const escapeHtml = (s) => s
.replace(/&/g, '&')
.replace(//g, '>');
let safe = escapeHtml(text);
// Convert markdown to HTML (order matters! match #### before ###)
let html = safe
// Headers — mark them so we don't insert extra
around them later
.replace(/^#### (.+)$/gm, '$1
')
.replace(/^### (.+)$/gm, '$1
')
.replace(/^## (.+)$/gm, '$1
')
.replace(/^# (.+)$/gm, '$1
')
// Bold
.replace(/\*\*([^\*]+?)\*\*/g, '$1')
.replace(/__([^_]+?)__/g, '$1')
// Italic
.replace(/(?$1')
.replace(/(?$1')
// Code
.replace(/`([^`]+?)`/g, '$1');
// Restore markdown link tokens
html = html.replace(/(?:__)?MDLINKTOKEN(\d+)(?:__)?/g, (match, idx) => {
const i = parseInt(idx, 10);
const item = linkTokens[i];
if (!item) return match;
return `${item.label}`;
});
// Restore bare URL tokens
html = html.replace(/(?:__)?MDURLTOKEN(\d+)(?:__)?/g, (match, idx) => {
const i = parseInt(idx, 10);
const item = urlTokens[i];
if (!item) return match;
return `${item.url}`;
});
// Process lists
const lines = html.split('\n');
let inUl = false;
let inOl = false;
let processedLines = [];
const closeLists = () => {
// Close any open lists. We deliberately do NOT tag these with
// so that a real line break between a bullet block and the next vendor header
// becomes a small visual gap between groups.
if (inUl) { processedLines.push(''); inUl = false; }
if (inOl) { processedLines.push(''); inOl = false; }
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Allow a bit of leading whitespace before bullets so lines like " - Phone: ..."
// are still treated as list items.
const ulMatch = line.match(/^\s*[\*\-]\s+(.+)$/);
const olMatch = line.match(/^\s*(\d+)\.\s+(.+)$/);
if (ulMatch) {
if (inOl) { processedLines.push(''); inOl = false; }
if (!inUl) {
// Start a new bullet list with minimal extra spacing.
// Tag with so we can strip the automatic
after block tags later.
processedLines.push('