Skip to content

Commit ba6ff34

Browse files
committed
fix(chat): include fenced code in copied messages
1 parent 85902eb commit ba6ff34

2 files changed

Lines changed: 126 additions & 5 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { toPlainText } from '@/app/workspace/[workspaceId]/components/message-actions/message-actions'
3+
4+
describe('toPlainText', () => {
5+
it('preserves language-tagged fenced code in copied messages', () => {
6+
const message = [
7+
"Sure — here's a useful example:",
8+
'',
9+
'```python',
10+
'import time',
11+
'from functools import wraps',
12+
'',
13+
'def retry(fn):',
14+
' def wrapper(*args, **kwargs):',
15+
' marker = `<thinking>keep this code</thinking>`',
16+
' return fn(*args, **kwargs)',
17+
'```',
18+
].join('\n')
19+
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+
)
44+
})
45+
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', () => {
64+
const message = [
65+
'**Before** `inline`',
66+
'',
67+
'<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',
81+
].join('\n')
82+
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')
96+
)
97+
})
98+
})

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

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,47 @@ import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
2424
import { useFolderStore } from '@/stores/folders/store'
2525

2626
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
30+
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+
)
2751

28-
function toPlainText(raw: string): string {
2952
return (
30-
raw
31-
// Strip special tags and their contents
32-
.replace(new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'), '')
53+
contentWithoutSpecialTags
3354
// Strip markdown
3455
.replace(/^#{1,6}\s+/gm, '')
3556
.replace(/\*\*(.+?)\*\*/g, '$1')
3657
.replace(/\*(.+?)\*/g, '$1')
37-
.replace(/`{3}[\s\S]*?`{3}/g, '')
3858
.replace(/`(.+?)`/g, '$1')
3959
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
4060
.replace(/^[>\-*]\s+/gm, '')
4161
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
4262
// Normalize whitespace
4363
.replace(/\n{3,}/g, '\n\n')
4464
.trim()
65+
.replace(CODE_BLOCK_PLACEHOLDER_PATTERN, (_match: string, index: string) => {
66+
return codeBlocks[Number(index)] ?? ''
67+
})
4568
)
4669
}
4770

0 commit comments

Comments
 (0)