Skip to content

Commit 4315f89

Browse files
committed
fix(chat): copy workspace links with real ids
1 parent 3fdb824 commit 4315f89

8 files changed

Lines changed: 81 additions & 23 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { JSONContent, MarkdownToken } from '@tiptap/core'
22
import { InputRule, Node } from '@tiptap/core'
3-
import { toSimHref } from './sim-link'
3+
import { toSimMarkdownLink } from './sim-link'
44
import type { MentionKind } from './types'
55

66
export interface MentionAttrs {
@@ -16,12 +16,7 @@ export interface MentionAttrs {
1616
*/
1717
const MENTION_MD_RE = /^\[((?:\\.|[^\]\\])+)\]\(sim:([a-z_]+)\/([^)\s]+)\)/
1818

19-
/** Escape `\`, `[`, `]` in a mention label so brackets in entity names can't break the link syntax. */
20-
function escapeLabel(label: string): string {
21-
return label.replace(/[\\[\]]/g, '\\$&')
22-
}
23-
24-
/** Inverse of {@link escapeLabel}, applied when parsing a mention back from markdown. */
19+
/** Inverse of the label escaping applied by {@link toSimMarkdownLink}. */
2520
function unescapeLabel(label: string): string {
2621
return label.replace(/\\([\\[\]])/g, '$1')
2722
}
@@ -96,12 +91,12 @@ export const MarkdownMention = Node.create({
9691
},
9792
renderMarkdown: (node: JSONContent): string => {
9893
const { kind, id, label } = (node.attrs ?? {}) as MentionAttrs
99-
return `[${escapeLabel(label)}](${toSimHref(kind, id)})`
94+
return toSimMarkdownLink(kind, id, label)
10095
},
10196

10297
renderText: ({ node }) => {
10398
const { kind, id, label } = node.attrs as MentionAttrs
104-
return `[${escapeLabel(label)}](${toSimHref(kind, id)})`
99+
return toSimMarkdownLink(kind, id, label)
105100
},
106101

107102
/**

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ export function toSimHref(kind: string, id: string): string {
99
return `${SIM_LINK_SCHEME}:${kind}/${id}`
1010
}
1111

12+
/** Builds portable mention Markdown while escaping characters that can break its label. */
13+
export function toSimMarkdownLink(kind: string, id: string, label: string): string {
14+
const escapedLabel = label.replace(/[\\[\]]/g, '\\$&')
15+
return `[${escapedLabel}](${toSimHref(kind, id)})`
16+
}
17+
1218
/**
1319
* Resolves the in-app route for a clicked `sim:` mention, or `null` when the kind has no navigable
1420
* destination. Each path matches the entity's real route: files open the file detail view,

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/workspace-resource-markdown.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ function endsInlineWord(value: string): boolean {
1111
return /[A-Za-z0-9_)]$/.test(value)
1212
}
1313

14-
function workspaceResourceLabel(data: WorkspaceResourceTagData): string {
14+
export function workspaceResourceLabel(data: WorkspaceResourceTagData): string {
1515
if (data.title) return data.title
1616
return data.type === 'file' ? (data.path ?? data.id ?? '') : (data.id ?? '')
1717
}

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

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
11
import { describe, expect, it } from 'vitest'
2+
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
23
import {
34
getCopyableMessageContent,
45
hasCopyableMessageContent,
56
toCopyableMarkdown,
67
} from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
78
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
89

10+
const WORKSPACE_FILES: WorkspaceFileRecord[] = [
11+
{
12+
id: 'file_bell',
13+
workspaceId: 'workspace-1',
14+
name: 'The Bell at Low Tide.md',
15+
key: 'workspace/workspace-1/file_bell',
16+
path: '/api/files/view/file_bell',
17+
size: 0,
18+
type: 'text/markdown',
19+
uploadedBy: 'user-1',
20+
uploadedAt: new Date(0),
21+
updatedAt: new Date(0),
22+
},
23+
]
24+
925
describe('toCopyableMarkdown', () => {
1026
it('preserves message Markdown, including fenced code and its language', () => {
1127
const message = [
@@ -50,15 +66,20 @@ describe('toCopyableMarkdown', () => {
5066
expect(toCopyableMarkdown(message)).toBe(message)
5167
})
5268

53-
it('preserves visible workspace resources as Markdown links', () => {
69+
it('copies workspace resources as portable Markdown links with real ids', () => {
5470
const message = [
5571
'Read',
56-
'<workspace_resource>{"type":"file","path":"files/notes.md","title":"notes.md"}</workspace_resource>',
57-
'for details.',
72+
'<workspace_resource>{"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}</workspace_resource>',
73+
'and',
74+
`<workspace_resource>${JSON.stringify({
75+
type: 'table',
76+
id: 'tbl_f26af6dae98d4222b014b250494d00fb',
77+
title: 'Checked_[rare]\\portal',
78+
})}</workspace_resource>.`,
5879
].join(' ')
5980

60-
expect(toCopyableMarkdown(message)).toBe(
61-
'Read [notes.md](<#wsres-file-files/notes.md>) for details.'
81+
expect(toCopyableMarkdown(message, WORKSPACE_FILES)).toBe(
82+
'Read [The Bell at Low Tide.md](sim:file/file_bell) and [Checked_\\[rare\\]\\\\portal](sim:table/tbl_f26af6dae98d4222b014b250494d00fb).'
6283
)
6384
})
6485

@@ -75,9 +96,7 @@ describe('toCopyableMarkdown', () => {
7596

7697
const content = getCopyableMessageContent(contentBlocks, 'Fallback without the resource.')
7798

78-
expect(toCopyableMarkdown(content)).toBe(
79-
'Read [notes.md](<#wsres-file-files/notes.md>) for details.'
80-
)
99+
expect(toCopyableMarkdown(content, WORKSPACE_FILES)).toBe('Read notes.md for details.')
81100
})
82101

83102
it('does not offer copy for blocks without visible text', () => {

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
2+
import { toSimMarkdownLink } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link'
13
import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize'
24
import {
35
appendInlineReferenceMarkdown,
4-
workspaceResourceReferenceMarkdown,
6+
workspaceResourceLabel,
57
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/workspace-resource-markdown'
6-
import { parseSpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
8+
import {
9+
parseSpecialTags,
10+
type WorkspaceResourceTagData,
11+
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
12+
import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
713
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
814

915
function isCopyableTextBlock(block: ContentBlock): boolean {
@@ -32,7 +38,19 @@ export function getCopyableMessageContent(
3238
return blockContent.trim() ? blockContent : fallbackContent
3339
}
3440

35-
export function toCopyableMarkdown(raw: string): string {
41+
function portableWorkspaceResourceMarkdown(
42+
data: WorkspaceResourceTagData,
43+
workspaceFiles: readonly WorkspaceFileRecord[]
44+
): string {
45+
const label = workspaceResourceLabel(data)
46+
const resource = resolveWorkspaceResourceRef({ ...data, title: label }, workspaceFiles)
47+
return resource ? toSimMarkdownLink(resource.type, resource.id, label) : label
48+
}
49+
50+
export function toCopyableMarkdown(
51+
raw: string,
52+
workspaceFiles: readonly WorkspaceFileRecord[] = []
53+
): string {
3654
const displayContent = sanitizeChatDisplayContent(raw)
3755
const { segments } = parseSpecialTags(displayContent, false)
3856

@@ -42,7 +60,7 @@ export function toCopyableMarkdown(raw: string): string {
4260
if (segment.type === 'workspace_resource') {
4361
return appendInlineReferenceMarkdown(
4462
markdown,
45-
workspaceResourceReferenceMarkdown(segment.data),
63+
portableWorkspaceResourceMarkdown(segment.data, workspaceFiles),
4664
segments[index + 1]
4765
)
4866
}

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { cn } from '@sim/emcn'
1414
import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual'
1515
import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase'
16+
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
1617
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
1718
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
1819
import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
@@ -58,6 +59,7 @@ import { shouldShowAssistantMessageActions } from './message-actions-visibility'
5859

5960
interface MothershipChatProps {
6061
messages: ChatMessage[]
62+
workspaceFiles: readonly WorkspaceFileRecord[]
6163
isSending: boolean
6264
isReconnecting?: boolean
6365
isLoading?: boolean
@@ -190,6 +192,7 @@ const UserMessageRow = memo(function UserMessageRow({
190192

191193
interface AssistantMessageRowProps {
192194
message: ChatMessage
195+
workspaceFiles: readonly WorkspaceFileRecord[]
193196
isStreaming: boolean
194197
isLast: boolean
195198
precedingUserContent?: string
@@ -206,6 +209,7 @@ interface AssistantMessageRowProps {
206209

207210
const AssistantMessageRow = memo(function AssistantMessageRow({
208211
message,
212+
workspaceFiles,
209213
isStreaming,
210214
isLast,
211215
precedingUserContent,
@@ -234,6 +238,10 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
234238
() => getCopyableMessageContent(blocks, message.content),
235239
[blocks, message.content]
236240
)
241+
const prepareContentForCopy = useCallback(
242+
(content: string) => toCopyableMarkdown(content, workspaceFiles),
243+
[workspaceFiles]
244+
)
237245

238246
const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '')
239247
if (!hasRenderableAssistant && !trimmedContent && !isStreaming) {
@@ -293,7 +301,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
293301
content={message.content}
294302
getCopyContent={getCopyContent}
295303
hasCopyContent={hasCopyableMessageContent(blocks, message.content)}
296-
prepareContentForCopy={toCopyableMarkdown}
304+
prepareContentForCopy={prepareContentForCopy}
297305
userQuery={precedingUserContent}
298306
requestId={message.requestId}
299307
messageId={message.id}
@@ -307,6 +315,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
307315

308316
export function MothershipChat({
309317
messages: messagesProp,
318+
workspaceFiles,
310319
isSending,
311320
isReconnecting = false,
312321
isLoading = false,
@@ -744,6 +753,7 @@ export function MothershipChat({
744753
) : (
745754
<AssistantMessageRow
746755
message={msg}
756+
workspaceFiles={workspaceFiles}
747757
isStreaming={isStreamActive && isLast}
748758
isLast={isLast}
749759
precedingUserContent={precedingUserContentByIndex[index]}

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
644644
) : (
645645
<MothershipChat
646646
messages={messages}
647+
workspaceFiles={workspaceFiles}
647648
isSending={isSending}
648649
isReconnecting={isReconnecting}
649650
isLoading={showChatSkeleton}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
type MothershipSendMessageDetail,
4747
} from '@/lib/mothership/events'
4848
import { captureEvent } from '@/lib/posthog/client'
49+
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
4950
import { generateWorkflowJson } from '@/lib/workflows/operations/import-export'
5051
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
5152
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components'
@@ -79,6 +80,7 @@ import {
7980
import { useFolderMap } from '@/hooks/queries/folders'
8081
import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree'
8182
import { useDuplicateWorkflowMutation, useWorkflowMap } from '@/hooks/queries/workflows'
83+
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
8284
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
8385
import { usePermissionConfig } from '@/hooks/use-permission-config'
8486
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -96,6 +98,7 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
9698

9799
const logger = createLogger('Panel')
98100
const EMPTY_COPILOT_CHATS: readonly CopilotChatListItem[] = []
101+
const EMPTY_WORKSPACE_FILES: readonly WorkspaceFileRecord[] = []
99102
/**
100103
* Panel component with resizable width and tab navigation that persists across page refreshes.
101104
*
@@ -162,6 +165,11 @@ export const Panel = memo(function Panel() {
162165
const isCopilotTabAvailable = isChatEnabled && !permissionConfig.hideCopilot
163166
const activeTab: PanelTab =
164167
storedActiveTab === 'copilot' && !isCopilotTabAvailable ? 'toolbar' : storedActiveTab
168+
const { data: copilotWorkspaceFiles = EMPTY_WORKSPACE_FILES } = useWorkspaceFiles(
169+
workspaceId,
170+
'active',
171+
{ enabled: activeTab === 'copilot' }
172+
)
165173
const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId })
166174
const duplicateWorkflowMutation = useDuplicateWorkflowMutation()
167175
const { data: workflows = {} } = useWorkflowMap(workspaceId)
@@ -930,6 +938,7 @@ export const Panel = memo(function Panel() {
930938
<MothershipChat
931939
className='min-h-0 flex-1'
932940
messages={copilotMessages}
941+
workspaceFiles={copilotWorkspaceFiles}
933942
isSending={copilotIsSending}
934943
isReconnecting={copilotIsReconnecting}
935944
onSubmit={handleCopilotSubmit}

0 commit comments

Comments
 (0)