Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/4395-si-modal-links-new-tab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

fix(addie): open external links in new tab from SI modal chat (closes #4395)
68 changes: 31 additions & 37 deletions server/public/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -3621,74 +3621,67 @@ <h2>Ask Addie</h2>
return `<div class="member-cards-container">${cards}</div>`;
}

// Render markdown using marked library
function renderMessage(text) {
// Configure marked for security and compatibility
marked.setOptions({
breaks: true, // Convert \n to <br>
gfm: true, // GitHub flavored markdown
});

// Helper to escape HTML attribute values
// Shared renderer factory — opens external links in a new tab (chat is an SPA;
// navigating away loses context). Used by both the main chat and SI modal paths.
function createAdcpMarkdownRenderer() {
const escapeAttr = (str) => str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');

// Check for embedded ADDIE_DATA blocks and extract them
const dataBlockRegex = /<!--ADDIE_DATA:([\s\S]*?):ADDIE_DATA-->/g;
const dataBlocks = [];
let processedText = text.replace(dataBlockRegex, (match, jsonStr) => {
try {
const data = JSON.parse(jsonStr);
const placeholder = `__ADDIE_DATA_PLACEHOLDER_${dataBlocks.length}__`;
dataBlocks.push(data);
return placeholder;
} catch (e) {
console.warn('Failed to parse ADDIE_DATA block:', e);
return ''; // Remove invalid blocks
}
});

// Use marked's built-in renderer with custom link and image handling
const renderer = new marked.Renderer();
renderer.link = function(href, title, text) {
// Handle marked v17+ which passes an object
if (typeof href === 'object') {
if (href !== null && typeof href === 'object') {
const link = href;
href = link.href;
title = link.title;
text = link.text;
}
// Validate URL scheme - only allow safe protocols
const safeHref = /^(https?:\/\/|mailto:|#|\/)/i.test(href) ? href : '#';
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
// Open all navigable links in new tab — chat is an SPA, navigating away loses context
const isAnchor = /^#/.test(href);
const targetAttr = !isAnchor ? ' target="_blank" rel="noopener noreferrer"' : '';
return `<a href="${escapeAttr(safeHref)}"${titleAttr}${targetAttr}>${text}</a>`;
};

// Custom image renderer
renderer.image = function(href, title, text) {
// Handle marked v17+ which passes an object
if (typeof href === 'object') {
if (href !== null && typeof href === 'object') {
const img = href;
href = img.href;
title = img.title;
text = img.text;
}
// Validate URL scheme - only allow safe protocols
const safeHref = /^(https?:\/\/|data:image\/)/i.test(href) ? escapeAttr(href) : '';
if (!safeHref) return '';
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
const altAttr = text ? ` alt="${escapeAttr(text)}"` : ' alt="Image"';
// Wrap in container to reserve space and prevent layout shift while loading
return `<a href="${safeHref}" target="_blank" rel="noopener noreferrer" class="chat-image-wrap"><img src="${safeHref}"${altAttr}${titleAttr} loading="lazy"></a>`;
};
return renderer;
}

// Render markdown using marked library
function renderMessage(text) {
marked.setOptions({ breaks: true, gfm: true });

// Check for embedded ADDIE_DATA blocks and extract them
const dataBlockRegex = /<!--ADDIE_DATA:([\s\S]*?):ADDIE_DATA-->/g;
const dataBlocks = [];
let processedText = text.replace(dataBlockRegex, (match, jsonStr) => {
try {
const data = JSON.parse(jsonStr);
const placeholder = `__ADDIE_DATA_PLACEHOLDER_${dataBlocks.length}__`;
dataBlocks.push(data);
return placeholder;
} catch (e) {
console.warn('Failed to parse ADDIE_DATA block:', e);
return ''; // Remove invalid blocks
}
});

let html = DOMPurify.sanitize(marked.parse(processedText, { renderer }));
let html = DOMPurify.sanitize(marked.parse(processedText, { renderer: createAdcpMarkdownRenderer() }));

// Replace placeholders with rendered components
dataBlocks.forEach((data, index) => {
Expand Down Expand Up @@ -4806,7 +4799,7 @@ <h2>Ask Addie</h2>

// Parse markdown for assistant messages
if (role === 'assistant' && window.marked) {
contentDiv.innerHTML = DOMPurify.sanitize(marked.parse(content || ''));
contentDiv.innerHTML = DOMPurify.sanitize(marked.parse(content || '', { breaks: true, gfm: true, renderer: createAdcpMarkdownRenderer() }));
} else {
contentDiv.textContent = content || '';
}
Expand Down Expand Up @@ -5514,6 +5507,7 @@ <h2>Ask Addie</h2>
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const siStreamRenderer = window.marked ? createAdcpMarkdownRenderer() : null;

while (true) {
const { done, value } = await reader.read();
Expand All @@ -5533,8 +5527,8 @@ <h2>Ask Addie</h2>
if (event.type === 'text') {
streamedText += event.text;
// Update content with markdown parsing
if (window.marked) {
contentDiv.innerHTML = DOMPurify.sanitize(marked.parse(streamedText));
if (siStreamRenderer) {
contentDiv.innerHTML = DOMPurify.sanitize(marked.parse(streamedText, { breaks: true, gfm: true, renderer: siStreamRenderer }));
} else {
contentDiv.textContent = streamedText;
}
Expand Down
Loading