Skip to content

Commit d409b0d

Browse files
committed
fix(chat): preserve markdown when copying messages
1 parent ba6ff34 commit d409b0d

2 files changed

Lines changed: 27 additions & 140 deletions

File tree

Lines changed: 17 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,34 @@
11
import { describe, expect, it } from 'vitest'
2-
import { toPlainText } from '@/app/workspace/[workspaceId]/components/message-actions/message-actions'
2+
import { toCopyableMarkdown } from '@/app/workspace/[workspaceId]/components/message-actions/message-actions'
33

4-
describe('toPlainText', () => {
5-
it('preserves language-tagged fenced code in copied messages', () => {
4+
describe('toCopyableMarkdown', () => {
5+
it('preserves message Markdown, including fenced code and its language', () => {
66
const message = [
7-
"Sure — here's a useful example:",
7+
'# Elevator diagnosis',
88
'',
9-
'```python',
10-
'import time',
11-
'from functools import wraps',
9+
'The bug is in `dispatch_legacy.py`:',
1210
'',
13-
'def retry(fn):',
14-
' def wrapper(*args, **kwargs):',
15-
' marker = `<thinking>keep this code</thinking>`',
16-
' return fn(*args, **kwargs)',
11+
'```python',
12+
'def next_stop(requests, current):',
13+
' ranked = sorted(requests)',
14+
' return ranked[1:]',
1715
'```',
16+
'',
17+
'**Result:** the closest request *was not* always selected.',
1818
].join('\n')
1919

20-
expect(toPlainText(message)).toBe(
21-
[
22-
"Sure — here's a useful example:",
23-
'',
24-
'import time',
25-
'from functools import wraps',
26-
'',
27-
'def retry(fn):',
28-
' def wrapper(*args, **kwargs):',
29-
' marker = `<thinking>keep this code</thinking>`',
30-
' return fn(*args, **kwargs)',
31-
].join('\n')
32-
)
33-
})
34-
35-
it.each([
36-
['tilde', '~~~typescript', '~~~'],
37-
['longer closing', '```typescript', '````'],
38-
])('preserves code in %s fences', (_name, openingFence, closingFence) => {
39-
const message = ['Before', '', openingFence, 'const value = **raw**', closingFence, '', 'After']
40-
41-
expect(toPlainText(message.join('\n'))).toBe(
42-
['Before', '', 'const value = **raw**', '', 'After'].join('\n')
43-
)
20+
expect(toCopyableMarkdown(message)).toBe(message)
4421
})
4522

46-
it.each(['```python', '~~~python'])(
47-
'preserves an unclosed %s fenced code block through the end of the message',
48-
(openingFence) => {
49-
const message = [
50-
'Before',
51-
'',
52-
openingFence,
53-
'def example(*args, **kwargs):',
54-
' return `raw`',
55-
]
56-
57-
expect(toPlainText(message.join('\n'))).toBe(
58-
['Before', '', 'def example(*args, **kwargs):', ' return `raw`'].join('\n')
59-
)
60-
}
61-
)
62-
63-
it('preserves multiple code blocks while cleaning surrounding message content', () => {
23+
it('removes internal structured tags without flattening surrounding Markdown', () => {
6424
const message = [
65-
'**Before** `inline`',
66-
'',
25+
'Before **formatted text**.',
6726
'<credential>remove this UI payload</credential>',
68-
'',
69-
'```typescript',
70-
'const marker = `<thinking>keep this code</thinking>`',
71-
'```',
72-
'',
73-
'Between **blocks**',
74-
'',
75-
'~~~python',
76-
'def example():',
77-
' return *args, **kwargs',
78-
'~~~',
79-
'',
80-
'After',
27+
'After [a link](https://example.com).',
8128
].join('\n')
8229

83-
expect(toPlainText(message)).toBe(
84-
[
85-
'Before inline',
86-
'',
87-
'const marker = `<thinking>keep this code</thinking>`',
88-
'',
89-
'Between blocks',
90-
'',
91-
'def example():',
92-
' return *args, **kwargs',
93-
'',
94-
'After',
95-
].join('\n')
30+
expect(toCopyableMarkdown(message)).toBe(
31+
['Before **formatted text**.', '', 'After [a link](https://example.com).'].join('\n')
9632
)
9733
})
9834
})

apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx

Lines changed: 10 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
ThumbsUp,
1616
Tooltip,
1717
toast,
18+
useCopyToClipboard,
1819
} from '@sim/emcn'
1920
import { useParams, useRouter } from 'next/navigation'
2021
import { isLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript'
@@ -24,48 +25,11 @@ import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
2425
import { useFolderStore } from '@/stores/folders/store'
2526

2627
const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file|question'
27-
const FENCED_CODE_BLOCK_PATTERN =
28-
/^ {0,3}(`{3,})[^\r\n]*\r?\n([\s\S]*?)(?:^ {0,3}\1`*[ \t]*\r?$|(?![\s\S]))|^ {0,3}(~{3,})[^\r\n]*\r?\n([\s\S]*?)(?:^ {0,3}\3~*[ \t]*\r?$|(?![\s\S]))/gm
29-
const CODE_BLOCK_PLACEHOLDER_PATTERN = /\u0000code-block-(\d+)\u0000/g
3028

31-
export function toPlainText(raw: string): string {
32-
const codeBlocks: string[] = []
33-
const contentWithCodePlaceholders = raw.replace(
34-
FENCED_CODE_BLOCK_PATTERN,
35-
(
36-
_match: string,
37-
_backtickFence: string | undefined,
38-
backtickCode: string | undefined,
39-
_tildeFence: string | undefined,
40-
tildeCode: string | undefined
41-
) => {
42-
const code = backtickCode ?? tildeCode ?? ''
43-
const index = codeBlocks.push(code.replace(/\r?\n$/, '')) - 1
44-
return `\u0000code-block-${index}\u0000`
45-
}
46-
)
47-
const contentWithoutSpecialTags = contentWithCodePlaceholders.replace(
48-
new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'),
49-
''
50-
)
51-
52-
return (
53-
contentWithoutSpecialTags
54-
// Strip markdown
55-
.replace(/^#{1,6}\s+/gm, '')
56-
.replace(/\*\*(.+?)\*\*/g, '$1')
57-
.replace(/\*(.+?)\*/g, '$1')
58-
.replace(/`(.+?)`/g, '$1')
59-
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
60-
.replace(/^[>\-*]\s+/gm, '')
61-
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
62-
// Normalize whitespace
63-
.replace(/\n{3,}/g, '\n\n')
64-
.trim()
65-
.replace(CODE_BLOCK_PLACEHOLDER_PATTERN, (_match: string, index: string) => {
66-
return codeBlocks[Number(index)] ?? ''
67-
})
68-
)
29+
export function toCopyableMarkdown(raw: string): string {
30+
return raw
31+
.replace(new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'), '')
32+
.trim()
6933
}
7034

7135
const ICON_CLASS = 'size-[14px]'
@@ -88,40 +52,27 @@ export const MessageActions = memo(function MessageActions({
8852
const router = useRouter()
8953
const params = useParams<{ workspaceId: string }>()
9054
const { chatId } = useChatSurface()
91-
const [copied, setCopied] = useState(false)
55+
const { copied, copy: copyMessage } = useCopyToClipboard({ resetMs: 1500 })
9256
const [copiedRequestId, setCopiedRequestId] = useState(false)
9357
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
9458
const [feedbackText, setFeedbackText] = useState('')
95-
const resetTimeoutRef = useRef<number | null>(null)
9659
const requestIdTimeoutRef = useRef<number | null>(null)
9760
const submitFeedback = useSubmitCopilotFeedback()
9861
const forkChat = useForkMothershipChat(params.workspaceId)
9962

10063
useEffect(() => {
10164
return () => {
102-
if (resetTimeoutRef.current !== null) {
103-
window.clearTimeout(resetTimeoutRef.current)
104-
}
10565
if (requestIdTimeoutRef.current !== null) {
10666
window.clearTimeout(requestIdTimeoutRef.current)
10767
}
10868
}
10969
}, [])
11070

111-
const copyToClipboard = async () => {
71+
const copyToClipboard = () => {
11272
if (!content) return
113-
const text = toPlainText(content)
114-
if (!text) return
115-
try {
116-
await navigator.clipboard.writeText(text)
117-
setCopied(true)
118-
if (resetTimeoutRef.current !== null) {
119-
window.clearTimeout(resetTimeoutRef.current)
120-
}
121-
resetTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500)
122-
} catch {
123-
/* clipboard unavailable */
124-
}
73+
const markdown = toCopyableMarkdown(content)
74+
if (!markdown) return
75+
void copyMessage(markdown)
12576
}
12677

12778
const copyRequestId = async () => {

0 commit comments

Comments
 (0)