diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx index adcab2cabeb..eb7c1b3cca2 100644 --- a/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx @@ -136,6 +136,7 @@ export function NativeChatMarkdown({ fadeWords={fadeWords} renderMermaid={!streaming} keepMermaidSourceWhilePending + unknownHtmlAsText data-word-fade={wordFadeArmed ? '' : undefined} data-block-fade={wordFadeArmed && streaming ? '' : undefined} className={cn('native-chat-markdown', className)} diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx index 089f62e2580..9636e8e3453 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx @@ -23,6 +23,7 @@ import { } from './comment-markdown-native-chat-file-links' import { CommentMarkdownBlockLineContext } from './comment-markdown-code-renderers' import { rehypeWordFade } from './comment-markdown-word-fade' +import { remarkUnknownHtmlAsText } from './comment-markdown-unknown-html-as-text' import { CommentMarkdownWords } from './CommentMarkdownWords' import { splitMarkdownTopLevelBlocks, @@ -207,6 +208,14 @@ const commentMarkdownSanitizeSchema = { } } +// Why: runs before remark-breaks so newlines inside the escaped HTML still break. +const unknownHtmlAsTextRemarkPlugins = [ + remarkUnknownHtmlAsText( + new Set((commentMarkdownSanitizeSchema.tagNames ?? []).map((name) => name.toLowerCase())) + ), + ...remarkPlugins +] + // Why: GitHub comments often include safe raw HTML (``, `
`, // `
`). Parse it, then sanitize immediately before React renders it. const rehypePlugins: MarkdownPlugins = [rehypeRaw, [rehypeSanitize, commentMarkdownSanitizeSchema]] @@ -251,6 +260,8 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & { /** Give each word its own `data-word` element, for a stylesheet to animate as words arrive. */ fadeWords?: boolean extension?: CommentMarkdownExtension + /** Show raw HTML that sanitize would strip as literal text instead of dropping its tags. */ + unknownHtmlAsText?: boolean } /** One render of the pipeline; memoized so an unchanged block is not parsed again. */ @@ -291,6 +302,7 @@ const CommentMarkdown = React.memo( growing = false, fadeWords = false, extension, + unknownHtmlAsText = false, ...rest }, ref @@ -318,12 +330,13 @@ const CommentMarkdown = React.memo( [extension] ) const activeRemarkPlugins = React.useMemo(() => { + const basePlugins = unknownHtmlAsText ? unknownHtmlAsTextRemarkPlugins : remarkPlugins const plugins = fileLinkExists - ? [...remarkPlugins, remarkNativeChatFileLinks(fileLinkExists)] - : remarkPlugins + ? [...basePlugins, remarkNativeChatFileLinks(fileLinkExists)] + : basePlugins const withExtension = extension ? [...plugins, ...extension.remarkPlugins] : plugins return githubRepo ? [...withExtension, remarkGitHubReferences(githubRepo)] : withExtension - }, [extension, fileLinkExists, githubRepo]) + }, [extension, fileLinkExists, githubRepo, unknownHtmlAsText]) const blocks = useMarkdownBlocks(content, growing) const [hadWordFade, setHadWordFade] = React.useState(fadeWords) if (fadeWords && !hadWordFade) { diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.unknown-html.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.unknown-html.test.tsx new file mode 100644 index 00000000000..9ea847f2428 --- /dev/null +++ b/src/renderer/src/components/sidebar/CommentMarkdown.unknown-html.test.tsx @@ -0,0 +1,151 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import CommentMarkdown from './CommentMarkdown' +import { NativeChatMarkdown } from '@/components/native-chat/NativeChatMarkdown' + +function renderChat(content: string): string { + return renderToStaticMarkup() +} + +describe('CommentMarkdown unknown HTML as text', () => { + it('keeps an unknown inline tag in native chat prose', () => { + const markup = renderChat('use\na ') + + expect(markup).toContain('a <se-tabs>') + }) + + it('keeps unknown opening and closing tags around their content', () => { + const markup = renderChat('wrap x here') + + expect(markup).toContain('wrap <se-tabs>x</se-tabs> here') + }) + + it('keeps a component-style tag that starts a block', () => { + const markup = renderChat('