From 162c11a92d6070cac77cbb388fe63999a8a57735 Mon Sep 17 00:00:00 2001 From: Anmol <91365474+anmol0b@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:04:11 +0530 Subject: [PATCH] fix(native-chat): show unknown HTML tags as text instead of dropping them (#30819) * fix(native-chat): show unknown HTML tags as text instead of dropping them Chat UI renders messages through CommentMarkdown, which parses raw HTML and then sanitizes it. Sanitize unwraps any tag outside its allowlist, so prose that mentions a tag lost it: "use a " rendered as "use a ". Add a remark plugin that turns raw HTML containing a non-allowlisted tag into literal text before sanitize runs. It reads the sanitize schema's own tag list, so the two cannot drift, and runs before remark-breaks so newlines inside it still break. Allowed tags (, ,
, ...) and inline code are unchanged. The behaviour is opt-in via a new `unknownHtmlAsText` prop and enabled only for native chat; GitHub, GitLab, Jira and Linear surfaces keep matching their host's rendering, which also strips unknown tags. Fixes #30604 * fix(native-chat): preserve markdown parsing around literal HTML * fix(native-chat): escape only unknown tags in mixed raw HTML blocks A raw HTML block that mixes allowed and unknown tags keeps rendering its allowed tags and comments; only the unknown tags are shown literally. --------- Co-authored-by: Neil --- .../native-chat/NativeChatMarkdown.tsx | 1 + .../components/sidebar/CommentMarkdown.tsx | 19 ++- .../CommentMarkdown.unknown-html.test.tsx | 151 ++++++++++++++++++ .../comment-markdown-unknown-html-as-text.ts | 78 +++++++++ 4 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/sidebar/CommentMarkdown.unknown-html.test.tsx create mode 100644 src/renderer/src/components/sidebar/comment-markdown-unknown-html-as-text.ts 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('