From 1952f4f358254fcb2a8490d24e0bd37e977e4703 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 19 Jul 2026 16:08:38 -0700 Subject: [PATCH] web: open links in chat in new tab --- .../components/chat/chat-messages-panel.tsx | 1 + .../components/markdown/markdown-content.tsx | 26 ++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/web/src/components/chat/chat-messages-panel.tsx b/web/src/components/chat/chat-messages-panel.tsx index 0ba25f8..f03d928 100644 --- a/web/src/components/chat/chat-messages-panel.tsx +++ b/web/src/components/chat/chat-messages-panel.tsx @@ -478,6 +478,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) { ) : message.content.trim() ? ( ) : null} diff --git a/web/src/components/markdown/markdown-content.tsx b/web/src/components/markdown/markdown-content.tsx index 1a0e066..d28baf5 100644 --- a/web/src/components/markdown/markdown-content.tsx +++ b/web/src/components/markdown/markdown-content.tsx @@ -10,6 +10,7 @@ type Props = { className?: string; mode?: MarkdownMode; resolveCitationIndex?: (href: string) => number | undefined; + openLinksInNewTab?: boolean; }; function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitationIndex?: (href: string) => number | undefined) { @@ -28,17 +29,30 @@ markdownRenderer.table = (token) => { return `
${renderTable(token)}
`; }; -function renderMarkdown(markdown: string) { - const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string; - return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] }); +function setNewTabLinkAttributes(currentNode: Element) { + if (currentNode.tagName !== "A") return; + currentNode.setAttribute("target", "_blank"); + currentNode.setAttribute("rel", "noopener noreferrer"); } -export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex }: Props) { +function renderMarkdown(markdown: string, openLinksInNewTab: boolean) { + const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string; + if (!openLinksInNewTab) return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] }); + + DOMPurify.addHook("afterSanitizeAttributes", setNewTabLinkAttributes); + try { + return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] }); + } finally { + DOMPurify.removeHook("afterSanitizeAttributes", setNewTabLinkAttributes); + } +} + +export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex, openLinksInNewTab = false }: Props) { const html = useMemo(() => { const prepared = mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown; - return renderMarkdown(prepared); - }, [markdown, mode, resolveCitationIndex]); + return renderMarkdown(prepared, openLinksInNewTab); + }, [markdown, mode, openLinksInNewTab, resolveCitationIndex]); return
; }