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.
This commit is contained in:
Brennan Benson
2026-08-16 16:40:39 -07:00
parent 1689e2f241
commit ca9a22f1b4
3 changed files with 81 additions and 1 deletions
@@ -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({
<>
<ImageAttachmentRefs blocks={prose} />
<CommentMarkdown
content={markdown}
content={escapeNativeChatUserMarkdown(markdown)}
variant="document"
className="text-sm"
onLinkClick={onLinkClick}
@@ -0,0 +1,54 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { escapeNativeChatUserMarkdown } from './native-chat-user-markdown'
function renderedText(content: string): string {
return renderToStaticMarkup(<CommentMarkdown variant="document" content={content} />)
.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')
})
})
@@ -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')
}