diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx
index b7b2c81194e..ebb84acf9a7 100644
--- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx
@@ -17,7 +17,6 @@ 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'
@@ -175,11 +174,14 @@ function MessageRow({
<>
>
) : (
diff --git a/src/renderer/src/components/native-chat/native-chat-user-markdown.test.tsx b/src/renderer/src/components/native-chat/native-chat-user-markdown.test.tsx
deleted file mode 100644
index 4162fe62d48..00000000000
--- a/src/renderer/src/components/native-chat/native-chat-user-markdown.test.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-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()
- .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
deleted file mode 100644
index bfc0cc5e718..00000000000
--- a/src/renderer/src/components/native-chat/native-chat-user-markdown.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-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')
-}
diff --git a/src/renderer/src/components/native-chat/native-chat-user-turn-render.test.tsx b/src/renderer/src/components/native-chat/native-chat-user-turn-render.test.tsx
new file mode 100644
index 00000000000..a8306e20f1a
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-user-turn-render.test.tsx
@@ -0,0 +1,57 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it } from 'vitest'
+import type { NativeChatMessage } from '../../../../shared/native-chat-types'
+import { NativeChatMessageList } from './NativeChatMessageList'
+import type { NativeChatLiveSession } from './use-native-chat-live-session'
+
+function sessionOf(messages: NativeChatMessage[]): NativeChatLiveSession {
+ return {
+ messages,
+ status: 'ready',
+ sessionId: 's1',
+ agent: 'claude',
+ hasMore: false,
+ loadingEarlier: false,
+ loadEarlier: () => {},
+ readPhase: 'ready'
+ }
+}
+
+function userTurn(text: string): NativeChatMessage {
+ return {
+ id: 'u1',
+ role: 'user',
+ blocks: [{ type: 'text', text }],
+ timestamp: 1,
+ source: 'transcript'
+ }
+}
+
+function renderedText(text: string): string {
+ return renderToStaticMarkup(
+
+ ).replace(/<[^>]*>/g, '')
+}
+
+describe('user turn rendering', () => {
+ // Wiring guard: the row must opt out of link reference definitions, or a
+ // marker line the transcript rules deliberately preserved still renders as
+ // an empty bubble.
+ it.each([
+ '[Image #1]: /tmp/a.png',
+ '- [Image #1]: /tmp/a.png',
+ '> [Image #1]: /tmp/a.png',
+ '[Image #1\nstill mine]: /tmp/a.png'
+ ])('shows a definition-shaped user line on screen', (typed) => {
+ expect(renderedText(typed)).toContain('[Image #1')
+ })
+
+ it('shows a literal marker the user typed without an attachment', () => {
+ expect(renderedText('keep [Image #1] literal')).toContain('keep [Image #1] literal')
+ })
+})
diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx
index 8673baedefa..4db2ed35411 100644
--- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx
+++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx
@@ -5,6 +5,7 @@ import remarkBreaks from 'remark-breaks'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import { cn } from '@/lib/utils'
+import { remarkDisableDefinitions } from './remark-disable-definitions'
import {
compactCommentMarkdownComponents,
createCompactCommentMarkdownComponents,
@@ -186,6 +187,9 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & {
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
expandImages?: boolean
+ /** Set when `content` is prose the user typed, not Markdown they authored, so
+ * a `[label]: target` line renders instead of silently disappearing. */
+ disableLinkDefinitions?: boolean
}
// Why forwardRef + rest props: Radix's HoverCardTrigger asChild merges a ref
@@ -201,6 +205,7 @@ const CommentMarkdown = React.memo(
onLinkClick,
allowFileUriLinks = false,
expandImages = false,
+ disableLinkDefinitions = false,
...rest
},
ref
@@ -217,10 +222,12 @@ const CommentMarkdown = React.memo(
? createDocumentCommentMarkdownComponents(onLinkClick)
: createCompactCommentMarkdownComponents(onLinkClick, expandImages)
}, [expandImages, variant, onLinkClick])
- const activeRemarkPlugins = React.useMemo(
- () => (githubRepo ? [...remarkPlugins, remarkGitHubReferences(githubRepo)] : remarkPlugins),
- [githubRepo]
- )
+ const activeRemarkPlugins = React.useMemo(() => {
+ const active = githubRepo
+ ? [...remarkPlugins, remarkGitHubReferences(githubRepo)]
+ : remarkPlugins
+ return disableLinkDefinitions ? [...active, remarkDisableDefinitions] : active
+ }, [disableLinkDefinitions, githubRepo])
return (
+ )
+ .replace(/<[^>]*>/g, '')
+ .trim()
+}
+
+describe('disableLinkDefinitions', () => {
+ // Each of these renders to nothing at all without the opt-in — the user's
+ // whole line disappears, which is the defect, not a formatting nicety.
+ it.each([
+ ['at the top level', '[Image #1]: /tmp/a.png', '[Image #1]: /tmp/a.png'],
+ ['inside a bullet', '- [Image #1]: /tmp/a.png', '[Image #1]: /tmp/a.png'],
+ ['inside an ordered item', '1. [Image #1]: /tmp/a.png', '[Image #1]: /tmp/a.png'],
+ ['inside a blockquote', '> [Image #1]: /tmp/a.png', '[Image #1]: /tmp/a.png'],
+ // A link label may span lines, so the swallow is not a single-line shape.
+ [
+ 'with a wrapped label',
+ '[Image #1\nstill mine]: /tmp/a.png',
+ '[Image #1\nstill mine]: /tmp/a.png'
+ ]
+ ])('keeps a definition-shaped line %s', (_name, typed, expected) => {
+ expect(rendered(typed, false)).toBe('')
+ expect(rendered(typed, true)).toBe(expected)
+ })
+
+ it('keeps the sibling content a swallowed definition took with it', () => {
+ const typed = '- [Image #1]: /tmp/a.png\n- second'
+
+ expect(rendered(typed, false)).toBe('second')
+ expect(rendered(typed, true)).toBe('[Image #1]: /tmp/a.png\nsecond')
+ })
+
+ it.each([
+ 'keep [Image #1] literal',
+ 'plain prose with a colon: like this',
+ 'see [the docs](https://example.com) here',
+ 'see [the docs][d] here',
+ '```\n[Image #1]: /tmp/a.png\n```',
+ '~~~\ncode\n```\n[Image #1]: /tmp/a.png\n~~~',
+ '| a | b |\n| - | - |\n| 1 | 2 |'
+ ])('leaves content the pipeline already rendered untouched', (typed) => {
+ expect(rendered(typed, true)).toBe(rendered(typed, false))
+ })
+
+ // The one intended difference: an authored definition stops being consumed,
+ // so its reference renders literally rather than as a resolved link.
+ it('renders an authored definition as the text it is', () => {
+ const typed = '[d]: https://example.com\n\nsee [the docs][d] here'
+
+ expect(rendered(typed, false)).toBe('see the docs here')
+ expect(rendered(typed, true)).toBe('[d]: https://example.com\nsee [the docs][d] here')
+ })
+})
diff --git a/src/renderer/src/components/sidebar/remark-disable-definitions.ts b/src/renderer/src/components/sidebar/remark-disable-definitions.ts
new file mode 100644
index 00000000000..67b1d46c9e0
--- /dev/null
+++ b/src/renderer/src/components/sidebar/remark-disable-definitions.ts
@@ -0,0 +1,19 @@
+import type { Processor } from 'unified'
+
+/**
+ * Stop CommonMark from reading `[label]: /some/target` as a link reference
+ * definition.
+ *
+ * Definitions render to nothing at all, so on content that is prose rather than
+ * authored Markdown — a chat turn the user typed — such a line silently deletes
+ * itself. Escaping the bracket in the source text only reaches lines at the top
+ * level; a definition inside a list item or blockquote, or one whose label wraps
+ * across lines, is still swallowed. Turning the construct off covers every
+ * container, and leaves inline links, reference *links*, and GFM intact.
+ */
+export function remarkDisableDefinitions(this: Processor): undefined {
+ const data = this.data()
+ const extensions = (data.micromarkExtensions ??= [])
+ extensions.push({ disable: { null: ['definition'] } })
+ return undefined
+}