Skip to content

Commit e066200

Browse files
committed
fix(chat): preserve workspace links when copying
1 parent 636f05a commit e066200

4 files changed

Lines changed: 85 additions & 49 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx

Lines changed: 5 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import { Checkbox, CopyCodeButton, cn, languages, highlight as prismHighlight }
1515
import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils'
1616
import { extractTextContent } from '@/lib/core/utils/react-node-text'
1717
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
18+
import {
19+
appendInlineReferenceMarkdown,
20+
workspaceResourceReferenceMarkdown,
21+
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/workspace-resource-markdown'
1822
import {
1923
type ContentSegment,
2024
type CredentialSubmissionPayload,
@@ -95,47 +99,6 @@ const ANIMATION_DRAIN_MS = 300
9599
*/
96100
const FADE_MAX_REVEALED_CHARS = 6000
97101

98-
function startsInlineWord(value: string): boolean {
99-
return /^[A-Za-z0-9_(]/.test(value)
100-
}
101-
102-
function endsInlineWord(value: string): boolean {
103-
return /[A-Za-z0-9_)]$/.test(value)
104-
}
105-
106-
function nextInlineSegmentLabel(segment?: ContentSegment): string {
107-
if (!segment) return ''
108-
// Thinking segments are never rendered, so they contribute no following text.
109-
if (segment.type === 'text') return segment.content
110-
if (segment.type === 'workspace_resource') return segment.data.title || segment.data.id || ''
111-
return ''
112-
}
113-
114-
function appendInlineReferenceMarkdown(
115-
currentMarkdown: string,
116-
referenceMarkdown: string,
117-
nextSegment?: ContentSegment
118-
): string {
119-
let nextMarkdown = currentMarkdown
120-
if (currentMarkdown && endsInlineWord(currentMarkdown) && !/\s$/.test(currentMarkdown)) {
121-
nextMarkdown += ' '
122-
}
123-
124-
nextMarkdown += referenceMarkdown
125-
126-
const followingText = nextInlineSegmentLabel(nextSegment)
127-
if (
128-
followingText &&
129-
startsInlineWord(followingText) &&
130-
!/^\s/.test(followingText) &&
131-
!/\s$/.test(nextMarkdown)
132-
) {
133-
nextMarkdown += ' '
134-
}
135-
136-
return nextMarkdown
137-
}
138-
139102
type TdProps = ComponentPropsWithoutRef<'td'>
140103
type ThProps = ComponentPropsWithoutRef<'th'>
141104

@@ -586,14 +549,9 @@ function ChatContentInner({
586549
const s = parsed.segments[i]
587550
const nextSegment = parsed.segments[i + 1]
588551
if (s.type === 'workspace_resource') {
589-
// Files are addressed by their encoded VFS path (copied verbatim from the tag);
590-
// workflows/tables/KBs by id. The angle-bracket link destination keeps the path
591-
// intact through markdown parsing (tolerates parens) without re-encoding it.
592-
const ref = s.data.type === 'file' ? (s.data.path ?? s.data.id ?? '') : (s.data.id ?? '')
593-
const label = s.data.title || ref
594552
pendingMarkdown = appendInlineReferenceMarkdown(
595553
pendingMarkdown,
596-
`[${label}](<#wsres-${s.data.type}-${ref}>)`,
554+
workspaceResourceReferenceMarkdown(s.data),
597555
nextSegment
598556
)
599557
} else if (s.type === 'thinking') {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type {
2+
ContentSegment,
3+
WorkspaceResourceTagData,
4+
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
5+
6+
function startsInlineWord(value: string): boolean {
7+
return /^[A-Za-z0-9_(]/.test(value)
8+
}
9+
10+
function endsInlineWord(value: string): boolean {
11+
return /[A-Za-z0-9_)]$/.test(value)
12+
}
13+
14+
function workspaceResourceLabel(data: WorkspaceResourceTagData): string {
15+
if (data.title) return data.title
16+
return data.type === 'file' ? (data.path ?? data.id ?? '') : (data.id ?? '')
17+
}
18+
19+
function nextInlineSegmentLabel(segment?: ContentSegment): string {
20+
if (!segment) return ''
21+
if (segment.type === 'text') return segment.content
22+
if (segment.type === 'workspace_resource') return segment.data.title || segment.data.id || ''
23+
return ''
24+
}
25+
26+
export function workspaceResourceReferenceMarkdown(data: WorkspaceResourceTagData): string {
27+
const ref = data.type === 'file' ? (data.path ?? data.id ?? '') : (data.id ?? '')
28+
return `[${workspaceResourceLabel(data)}](<#wsres-${data.type}-${ref}>)`
29+
}
30+
31+
export function appendInlineReferenceMarkdown(
32+
currentMarkdown: string,
33+
referenceMarkdown: string,
34+
nextSegment?: ContentSegment
35+
): string {
36+
let nextMarkdown = currentMarkdown
37+
if (currentMarkdown && endsInlineWord(currentMarkdown) && !/\s$/.test(currentMarkdown)) {
38+
nextMarkdown += ' '
39+
}
40+
41+
nextMarkdown += referenceMarkdown
42+
43+
const followingText = nextInlineSegmentLabel(nextSegment)
44+
if (
45+
followingText &&
46+
startsInlineWord(followingText) &&
47+
!/^\s/.test(followingText) &&
48+
!/\s$/.test(nextMarkdown)
49+
) {
50+
nextMarkdown += ' '
51+
}
52+
53+
return nextMarkdown
54+
}

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,16 @@ describe('toCopyableMarkdown', () => {
4444

4545
expect(toCopyableMarkdown(message)).toBe(message)
4646
})
47+
48+
it('preserves visible workspace resources as Markdown links', () => {
49+
const message = [
50+
'Read',
51+
'<workspace_resource>{"type":"file","path":"files/notes.md","title":"notes.md"}</workspace_resource>',
52+
'for details.',
53+
].join(' ')
54+
55+
expect(toCopyableMarkdown(message)).toBe(
56+
'Read [notes.md](<#wsres-file-files/notes.md>) for details.'
57+
)
58+
})
4759
})
Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize'
2+
import {
3+
appendInlineReferenceMarkdown,
4+
workspaceResourceReferenceMarkdown,
5+
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/workspace-resource-markdown'
26
import { parseSpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
37

48
export function toCopyableMarkdown(raw: string): string {
59
const displayContent = sanitizeChatDisplayContent(raw)
610
const { segments } = parseSpecialTags(displayContent, false)
711

812
return segments
9-
.reduce((markdown, segment) => {
10-
return segment.type === 'text' ? markdown + segment.content : markdown
13+
.reduce((markdown, segment, index) => {
14+
if (segment.type === 'text') return markdown + segment.content
15+
if (segment.type === 'workspace_resource') {
16+
return appendInlineReferenceMarkdown(
17+
markdown,
18+
workspaceResourceReferenceMarkdown(segment.data),
19+
segments[index + 1]
20+
)
21+
}
22+
return markdown
1123
}, '')
1224
.trim()
1325
}

0 commit comments

Comments
 (0)