From 6a9a0e8a75867767fa8f73bf16aedc08eeb4d7ff Mon Sep 17 00:00:00 2001 From: 0utsights Date: Sat, 18 Jul 2026 14:43:38 -0400 Subject: [PATCH 1/2] fix(web): preserve literal user markup for PR #4133 Render user-authored XML-like source as escaped text while retaining sanitized assistant HTML, with regression coverage for custom tags, code, comparisons, and unsafe input. Co-authored-by: codex --- apps/web/src/components/ChatMarkdown.tsx | 9 +- .../components/chat/MessagesTimeline.test.tsx | 129 +++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 4 + 3 files changed, 140 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ec88bc912f00..001d696cd9ab 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -117,6 +117,8 @@ interface ChatMarkdownProps { className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; + /** Parse sanitized raw HTML instead of displaying its source text. */ + parseRawHtml?: boolean; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; @@ -1360,6 +1362,7 @@ function ChatMarkdown({ skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, + parseRawHtml = true, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -1777,6 +1780,9 @@ function ChatMarkdown({ ]); /* eslint-enable react/no-unstable-nested-components */ + // react-markdown converts unparsed HTML nodes to text when skipHtml is false. + // Keep that behavior explicit because literal mode depends on escaping the + // complete source token instead of dropping it from the rendered message. return (
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 194edc0bd5bb..d13b09ca3b23 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -225,6 +225,17 @@ function buildUserTimelineEntry(text: string) { }; } +function buildAssistantTimelineEntry(text: string) { + const entry = buildUserTimelineEntry(text); + return { + ...entry, + message: { + ...entry.message, + role: "assistant" as const, + }, + }; +} + describe("MessagesTimeline", () => { it("uses the larger leading inset only when the top fade is enabled", () => { const timelineEntries = [buildUserTimelineEntry("Hello")]; @@ -464,7 +475,123 @@ describe("MessagesTimeline", () => { expect(markup).toContain("rounded-2xl bg-message p-3"); }); - it("renders inline terminal labels with the composer chip UI", () => { + it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ', + 'Before inside after', + " in your context?", + "Comparison: 2 < 3 and 5 > 4.", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain("<global-agent-instructions scope="workspace">"); + expect(markup).toContain( + "Before <nested data-value="a&b">inside</nested> after", + ); + expect(markup).toContain("</global-agent-instructions> in your context?"); + expect(markup).toContain("Comparison: 2 < 3 and 5 > 4."); + }); + + it("preserves XML-like source inside user code spans and fences", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + `', + "", + "```xml", + '', + "```", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain('<tag attr="x">'); + expect(markup).toContain("<root><child enabled="true" /></root>"); + }); + + it("renders unsafe user HTML as inert source text", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + globalThis.__t3Xss = 1', + ), + ]} + />, + ); + + expect(markup).toContain("<script>globalThis.__t3Xss = 1</script>"); + expect(markup).toContain( + "<img src="x" onerror="globalThis.__t3Xss = 2">", + ); + expect(markup).not.toMatch(/)/i); + expect(markup).not.toMatch(/)/i); + }); + + it("continues to render sanitized raw HTML in assistant messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + MoreDetails"), + ]} + />, + ); + + expect(markup).toContain('data-markdown-details=""'); + expect(markup).toContain("More"); + expect(markup).not.toContain("<details>"); + }); + + it("sanitizes executable HTML while preserving supported assistant markup", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ', + "Safe details", + "", + '', + 'Unsafe link', + "", + ].join(""), + ), + ]} + />, + ); + + expect(markup).toContain('data-markdown-details=""'); + expect(markup).toContain("Safe details"); + expect(markup).not.toMatch(/)/i); + expect(markup).not.toContain("onclick="); + expect(markup).not.toContain("onerror="); + expect(markup).not.toContain("javascript:"); + expect(markup).not.toContain("globalThis.__t3Xss"); + }); + + it("renders inline terminal labels with the composer chip UI", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( ) : null} {trailingWhitespace ? : null} @@ -1714,6 +1715,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} />
) : null @@ -1802,6 +1804,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} />, ); } else if (inlinePrefix.length === 0) { @@ -1827,6 +1830,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} /> ); }); From 9e2aa9b31d30cd19ba9599ac5c105d1896292c59 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 15 Aug 2026 13:57:36 +0200 Subject: [PATCH 2/2] fix(web): suppress markdown title tooltips in user messages Co-authored-by: codex --- apps/web/src/components/ChatMarkdown.tsx | 5 ++++- .../components/chat/MessagesTimeline.test.tsx | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 001d696cd9ab..c4548540e2ce 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1625,7 +1625,7 @@ function ChatMarkdown({ /> ); }, - a({ node, href, children, ...props }) { + a({ node, href, children, title: _title, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { @@ -1710,6 +1710,9 @@ function ChatMarkdown({ props.className, ); }, + img({ node: _node, title: _title, ...props }) { + return ; + }, code({ node, children, className, ...props }) { if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index d13b09ca3b23..617ee0b80d1c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -524,6 +524,25 @@ describe("MessagesTimeline", () => { expect(markup).toContain("<root><child enabled="true" /></root>"); }); + it("does not render markdown title attributes in user messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('href="https://example.com"'); + expect(markup).toContain('src="https://example.com/image.png"'); + expect(markup).not.toContain('title="link tip"'); + expect(markup).not.toContain('title="image tip"'); + }); + it("renders unsafe user HTML as inert source text", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup(