From ca9a22f1b41fa83990b186c7f3bb1316c8d497c1 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 16 Aug 2026 16:40:39 -0700
Subject: [PATCH] fix(native-chat): keep definition-shaped user lines out of
Markdown's mouth
A preserved literal marker can still vanish one layer later: CommonMark reads
`[Image #1]: /tmp/a.png` as a link reference definition, so a user turn that is
only that line renders as an empty bubble. Escape the line-leading bracket on
user turns before they reach the Markdown renderer.
Assistant prose is untouched, and the escape is output-neutral everywhere else,
including inside fenced code.
---
.../native-chat/NativeChatMessageList.tsx | 3 +-
.../native-chat-user-markdown.test.tsx | 54 +++++++++++++++++++
.../native-chat/native-chat-user-markdown.ts | 25 +++++++++
3 files changed, 81 insertions(+), 1 deletion(-)
create mode 100644 src/renderer/src/components/native-chat/native-chat-user-markdown.test.tsx
create mode 100644 src/renderer/src/components/native-chat/native-chat-user-markdown.ts
diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx
index 354732ba0d8..b7b2c81194e 100644
--- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx
@@ -17,6 +17,7 @@ import { stripNoiseMessages } from './native-chat-noise'
import { foldToolMessages, splitNativeChatBlocks } from './native-chat-tool-fold'
import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './native-chat-autoscroll'
import { isNativeChatPastedImagePath } from './native-chat-image-paste'
+import { escapeNativeChatUserMarkdown } from './native-chat-user-markdown'
import { NativeChatToolRun } from './NativeChatToolRun'
import { NativeChatCopyButton } from './NativeChatCopyButton'
import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming'
@@ -174,7 +175,7 @@ function MessageRow({
<>
)
+ .replace(/<[^>]*>/g, '')
+ .trim()
+}
+
+describe('escapeNativeChatUserMarkdown', () => {
+ // The swallow this guards against: without the escape the marker line is a
+ // CommonMark link reference definition and the whole turn renders empty.
+ it('keeps a marker line the raw Markdown pipeline drops entirely', () => {
+ const typed = '[Image #1]: /tmp/a.png'
+
+ expect(renderedText(typed)).toBe('')
+ expect(renderedText(escapeNativeChatUserMarkdown(typed))).toBe('[Image #1]: /tmp/a.png')
+ })
+
+ it('keeps a definition-shaped line that precedes other prose', () => {
+ const typed = '[Image #1]: /tmp/a.png\n\nwhat do you make of it?'
+
+ expect(renderedText(typed)).toBe('what do you make of it?')
+ expect(renderedText(escapeNativeChatUserMarkdown(typed))).toBe(
+ '[Image #1]: /tmp/a.png\nwhat do you make of it?'
+ )
+ })
+
+ it.each([
+ 'keep [Image #1] literal',
+ '[Image #1] describe this',
+ 'here is my note\n[Image #1]: /tmp/a.png\nend',
+ '[Image #1]: /tmp/a.png and more text after',
+ '```\n[Image #1]: /tmp/a.png\n```',
+ '- [Image #1]: /tmp/a.png',
+ 'plain prose with a colon: like this'
+ ])('renders text the pipeline already showed identically', (typed) => {
+ expect(renderedText(escapeNativeChatUserMarkdown(typed))).toBe(renderedText(typed))
+ })
+
+ it.each(['keep [Image #1] literal', '```\n[Image #1]: /tmp/a.png\n```'])(
+ 'leaves lines that cannot open a definition byte-identical',
+ (typed) => {
+ expect(escapeNativeChatUserMarkdown(typed)).toBe(typed)
+ }
+ )
+
+ it('escapes only the line-leading bracket, once per line', () => {
+ expect(escapeNativeChatUserMarkdown('[a]: x\n[b]: y')).toBe('\\[a]: x\n\\[b]: y')
+ expect(escapeNativeChatUserMarkdown(' [a]: x')).toBe(' \\[a]: x')
+ })
+})
diff --git a/src/renderer/src/components/native-chat/native-chat-user-markdown.ts b/src/renderer/src/components/native-chat/native-chat-user-markdown.ts
new file mode 100644
index 00000000000..bfc0cc5e718
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-user-markdown.ts
@@ -0,0 +1,25 @@
+const LINK_REFERENCE_DEFINITION_LINE = /^( {0,3})\[(?=[^\]\n]*\]:)/
+const FENCE_LINE = /^ {0,3}(?:`{3,}|~{3,})/
+
+/**
+ * A user turn is prose, not authored Markdown. CommonMark reads a line like
+ * `[Image #1]: /tmp/a.png` as a link reference definition and renders nothing at
+ * all, so a marker the transcript rules preserve would still vanish on screen.
+ * Escaping the opening bracket is output-neutral for every other line.
+ */
+export function escapeNativeChatUserMarkdown(markdown: string): string {
+ if (!markdown.includes(']:')) {
+ return markdown
+ }
+ let inFence = false
+ return markdown
+ .split('\n')
+ .map((line) => {
+ if (FENCE_LINE.test(line)) {
+ inFence = !inFence
+ return line
+ }
+ return inFence ? line : line.replace(LINK_REFERENCE_DEFINITION_LINE, '$1\\[')
+ })
+ .join('\n')
+}