Skip to content

Commit 930d6ca

Browse files
committed
fix(chat): make copied resource links round trip
1 parent c943386 commit 930d6ca

10 files changed

Lines changed: 153 additions & 39 deletions

File tree

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ interface MessageActionsProps {
3232
content: string
3333
getCopyContent?: () => string
3434
hasCopyContent?: boolean
35-
prepareContentForCopy?: (content: string) => string
35+
prepareContentForCopy?: (content: string) => string | Promise<string>
3636
userQuery?: string
3737
requestId?: string
3838
messageId?: string
@@ -66,12 +66,12 @@ export const MessageActions = memo(function MessageActions({
6666
}
6767
}, [])
6868

69-
const copyToClipboard = () => {
69+
const copyToClipboard = async () => {
7070
const contentToCopy = getCopyContent?.() ?? content
7171
if (!contentToCopy) return
72-
const markdown = prepareContentForCopy?.(contentToCopy) ?? contentToCopy
72+
const markdown = (await prepareContentForCopy?.(contentToCopy)) ?? contentToCopy
7373
if (!markdown) return
74-
void copyMessage(markdown)
74+
await copyMessage(markdown)
7575
}
7676

7777
const copyRequestId = async () => {

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

Lines changed: 3 additions & 8 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 { toSimMarkdownLink } from './sim-link'
3+
import { fromSimMarkdownLabel, toSimMarkdownLink } from './sim-link'
44
import type { MentionKind } from './types'
55

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

19-
/** Inverse of the label escaping applied by {@link toSimMarkdownLink}. */
20-
function unescapeLabel(label: string): string {
21-
return label.replace(/\\([\\[\]])/g, '$1')
22-
}
23-
2419
/** Custom fields the mention tokenizer hangs on the marked token (all optional, like the image token). */
2520
interface MentionTokenFields {
2621
label?: string
@@ -86,7 +81,7 @@ export const MarkdownMention = Node.create({
8681
const { kind, id, label } = token as MentionTokenFields
8782
return {
8883
type: 'mention',
89-
attrs: { kind: kind ?? '', id: id ?? '', label: unescapeLabel(label ?? '') },
84+
attrs: { kind: kind ?? '', id: id ?? '', label: fromSimMarkdownLabel(label ?? '') },
9085
}
9186
},
9287
renderMarkdown: (node: JSONContent): string => {
@@ -118,7 +113,7 @@ export const MarkdownMention = Node.create({
118113
state.tr.replaceWith(
119114
range.from,
120115
range.to,
121-
type.create({ kind, id, label: unescapeLabel(rawLabel ?? '') })
116+
type.create({ kind, id, label: fromSimMarkdownLabel(rawLabel ?? '') })
122117
)
123118
},
124119
}),

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export function toSimMarkdownLink(kind: string, id: string, label: string): stri
1515
return `[${escapedLabel}](${toSimHref(kind, id)})`
1616
}
1717

18+
/** Restores a label serialized by {@link toSimMarkdownLink}. */
19+
export function fromSimMarkdownLabel(label: string): string {
20+
return label.replace(/\\([\\[\]])/g, '$1')
21+
}
22+
1823
/**
1924
* Resolves the in-app route for a clicked `sim:` mention, or `null` when the kind has no navigable
2025
* destination. Each path matches the entity's real route: files open the file detail view,

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

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ vi.mock('@/lib/auth/auth-client', () => ({
66

77
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
88
import { getRenderableMessageText } from '@/app/workspace/[workspaceId]/home/components/message-content'
9-
import { toCopyableMarkdown } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
9+
import {
10+
prepareCopyableMarkdown,
11+
serializeCopyableMarkdown,
12+
toCopyableMarkdown,
13+
} from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
14+
import { parseChipLinks } from '@/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec'
1015
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
1116

1217
const WORKSPACE_FILES: WorkspaceFileRecord[] = [
@@ -80,9 +85,59 @@ describe('toCopyableMarkdown', () => {
8085
})}</workspace_resource>.`,
8186
].join(' ')
8287

83-
expect(toCopyableMarkdown(message, WORKSPACE_FILES)).toBe(
88+
const markdown = toCopyableMarkdown(message, WORKSPACE_FILES)
89+
90+
expect(markdown).toBe(
8491
'Read [The Bell at Low Tide.md](sim:file/file_bell) and [Checked_\\[rare\\]\\\\portal](sim:table/tbl_f26af6dae98d4222b014b250494d00fb).'
8592
)
93+
expect(parseChipLinks(markdown)).toEqual([
94+
{
95+
kind: 'file',
96+
id: 'file_bell',
97+
label: 'The Bell at Low Tide.md',
98+
start: 5,
99+
end: 50,
100+
},
101+
{
102+
kind: 'table',
103+
id: 'tbl_f26af6dae98d4222b014b250494d00fb',
104+
label: 'Checked_[rare]\\portal',
105+
start: 55,
106+
end: 129,
107+
},
108+
])
109+
})
110+
111+
it('reports file resources that need refreshed metadata before copying', () => {
112+
const message =
113+
'Read <workspace_resource>{"type":"file","path":"files/notes.md","title":"notes.md"}</workspace_resource>.'
114+
115+
expect(serializeCopyableMarkdown(message)).toEqual({
116+
markdown: 'Read notes.md.',
117+
hasUnresolvedFile: true,
118+
})
119+
})
120+
121+
it('refreshes missing file metadata before producing copyable Markdown', async () => {
122+
const message =
123+
'Read <workspace_resource>{"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}</workspace_resource>.'
124+
const refreshWorkspaceFiles = vi.fn().mockResolvedValue(WORKSPACE_FILES)
125+
126+
await expect(prepareCopyableMarkdown(message, [], refreshWorkspaceFiles)).resolves.toBe(
127+
'Read [The Bell at Low Tide.md](sim:file/file_bell).'
128+
)
129+
expect(refreshWorkspaceFiles).toHaveBeenCalledOnce()
130+
})
131+
132+
it('does not refresh metadata when all workspace resources already resolve', async () => {
133+
const message =
134+
'Read <workspace_resource>{"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}</workspace_resource>.'
135+
const refreshWorkspaceFiles = vi.fn()
136+
137+
await expect(
138+
prepareCopyableMarkdown(message, WORKSPACE_FILES, refreshWorkspaceFiles)
139+
).resolves.toBe('Read [The Bell at Low Tide.md](sim:file/file_bell).')
140+
expect(refreshWorkspaceFiles).not.toHaveBeenCalled()
86141
})
87142

88143
it('copies workspace resources from visible content blocks', () => {

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

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,33 +11,66 @@ import {
1111
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
1212
import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
1313

14+
interface PortableWorkspaceResourceMarkdown {
15+
markdown: string
16+
hasUnresolvedFile: boolean
17+
}
18+
1419
function portableWorkspaceResourceMarkdown(
1520
data: WorkspaceResourceTagData,
1621
workspaceFiles: readonly WorkspaceFileRecord[]
17-
): string {
22+
): PortableWorkspaceResourceMarkdown {
1823
const label = workspaceResourceLabel(data)
1924
const resource = resolveWorkspaceResourceRef({ ...data, title: label }, workspaceFiles)
20-
return resource ? toSimMarkdownLink(resource.type, resource.id, label) : label
25+
return {
26+
markdown: resource ? toSimMarkdownLink(resource.type, resource.id, label) : label,
27+
hasUnresolvedFile: data.type === 'file' && !resource,
28+
}
2129
}
2230

23-
export function toCopyableMarkdown(
31+
export interface CopyableMarkdownResult {
32+
markdown: string
33+
hasUnresolvedFile: boolean
34+
}
35+
36+
export function serializeCopyableMarkdown(
2437
raw: string,
2538
workspaceFiles: readonly WorkspaceFileRecord[] = []
26-
): string {
39+
): CopyableMarkdownResult {
2740
const displayContent = sanitizeChatDisplayContent(raw)
2841
const { segments } = parseSpecialTags(displayContent, false)
42+
let hasUnresolvedFile = false
2943

30-
return segments
44+
const markdown = segments
3145
.reduce((markdown, segment, index) => {
3246
if (segment.type === 'text') return markdown + segment.content
3347
if (segment.type === 'workspace_resource') {
34-
return appendInlineReferenceMarkdown(
35-
markdown,
36-
portableWorkspaceResourceMarkdown(segment.data, workspaceFiles),
37-
segments[index + 1]
38-
)
48+
const portable = portableWorkspaceResourceMarkdown(segment.data, workspaceFiles)
49+
hasUnresolvedFile ||= portable.hasUnresolvedFile
50+
return appendInlineReferenceMarkdown(markdown, portable.markdown, segments[index + 1])
3951
}
4052
return markdown
4153
}, '')
4254
.trim()
55+
56+
return { markdown, hasUnresolvedFile }
57+
}
58+
59+
export function toCopyableMarkdown(
60+
raw: string,
61+
workspaceFiles: readonly WorkspaceFileRecord[] = []
62+
): string {
63+
return serializeCopyableMarkdown(raw, workspaceFiles).markdown
64+
}
65+
66+
export async function prepareCopyableMarkdown(
67+
raw: string,
68+
workspaceFiles: readonly WorkspaceFileRecord[],
69+
refreshWorkspaceFiles: () => Promise<readonly WorkspaceFileRecord[]>
70+
): Promise<string> {
71+
const initial = serializeCopyableMarkdown(raw, workspaceFiles)
72+
if (!initial.hasUnresolvedFile) return initial.markdown
73+
74+
const refreshedFiles = await refreshWorkspaceFiles().catch(() => workspaceFiles)
75+
return toCopyableMarkdown(raw, refreshedFiles)
4376
}

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
useState,
1212
} from 'react'
1313
import { cn } from '@sim/emcn'
14+
import { useQueryClient } from '@tanstack/react-query'
1415
import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual'
1516
import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase'
1617
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
@@ -31,7 +32,7 @@ import {
3132
parseLastCredentialTag,
3233
parseLastQuestionTag,
3334
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
34-
import { toCopyableMarkdown } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
35+
import { prepareCopyableMarkdown } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
3536
import { nextSizerFloor } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor'
3637
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
3738
import {
@@ -49,12 +50,14 @@ import type {
4950
WorkspaceResourceRef,
5051
} from '@/app/workspace/[workspaceId]/home/types'
5152
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
53+
import { fetchFreshWorkspaceFiles } from '@/hooks/queries/workspace-files'
5254
import { useAutoScroll } from '@/hooks/use-auto-scroll'
5355
import type { ChatContext } from '@/stores/panel'
5456
import { MothershipChatSkeleton } from './components/mothership-chat-skeleton'
5557
import { shouldShowAssistantMessageActions } from './message-actions-visibility'
5658

5759
interface MothershipChatProps {
60+
workspaceId: string
5861
messages: ChatMessage[]
5962
workspaceFiles: readonly WorkspaceFileRecord[]
6063
isSending: boolean
@@ -190,6 +193,7 @@ const UserMessageRow = memo(function UserMessageRow({
190193
interface AssistantMessageRowProps {
191194
message: ChatMessage
192195
workspaceFiles: readonly WorkspaceFileRecord[]
196+
refreshWorkspaceFiles: () => Promise<readonly WorkspaceFileRecord[]>
193197
isStreaming: boolean
194198
isLast: boolean
195199
precedingUserContent?: string
@@ -207,6 +211,7 @@ interface AssistantMessageRowProps {
207211
const AssistantMessageRow = memo(function AssistantMessageRow({
208212
message,
209213
workspaceFiles,
214+
refreshWorkspaceFiles,
210215
isStreaming,
211216
isLast,
212217
precedingUserContent,
@@ -236,8 +241,8 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
236241
[blocks, message.content]
237242
)
238243
const prepareContentForCopy = useCallback(
239-
(content: string) => toCopyableMarkdown(content, workspaceFiles),
240-
[workspaceFiles]
244+
(content: string) => prepareCopyableMarkdown(content, workspaceFiles, refreshWorkspaceFiles),
245+
[refreshWorkspaceFiles, workspaceFiles]
241246
)
242247

243248
const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '')
@@ -311,6 +316,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
311316
})
312317

313318
export function MothershipChat({
319+
workspaceId,
314320
messages: messagesProp,
315321
workspaceFiles,
316322
isSending,
@@ -337,6 +343,7 @@ export function MothershipChat({
337343
onInputAnimationEnd,
338344
className,
339345
}: MothershipChatProps) {
346+
const queryClient = useQueryClient()
340347
const styles = LAYOUT_STYLES[layout]
341348
const isStreamActive = isSending || isReconnecting
342349
/**
@@ -355,6 +362,10 @@ export function MothershipChat({
355362
const heldHighWaterRef = useRef(0)
356363
const floorChatRef = useRef<string | undefined>(undefined)
357364
const floorDrainRafRef = useRef(0)
365+
const refreshWorkspaceFiles = useCallback(
366+
() => fetchFreshWorkspaceFiles(queryClient, workspaceId),
367+
[queryClient, workspaceId]
368+
)
358369
useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), [])
359370

360371
/**
@@ -751,6 +762,7 @@ export function MothershipChat({
751762
<AssistantMessageRow
752763
message={msg}
753764
workspaceFiles={workspaceFiles}
765+
refreshWorkspaceFiles={refreshWorkspaceFiles}
754766
isStreaming={isStreamActive && isLast}
755767
isLast={isLast}
756768
precedingUserContent={precedingUserContentByIndex[index]}

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import {
2+
fromSimMarkdownLabel,
3+
SIM_LINK_SCHEME,
4+
toSimMarkdownLink,
5+
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link'
16
import {
27
computeMentionHighlightRanges,
38
extractContextTokens,
@@ -6,10 +11,6 @@ import {
611
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
712
import type { ChatContext } from '@/stores/panel'
813

9-
/** URI scheme for portable chip links (`[label](sim:kind/id)`). Custom so only
10-
* our own links — never generic markdown — are parsed back into chips. */
11-
const CHIP_LINK_SCHEME = 'sim'
12-
1314
/**
1415
* Every chip kind that carries a single stable identifier → the
1516
* {@link ChatContext} id field encoded in `sim:<kind>/<id>`. This is the one map
@@ -45,12 +46,12 @@ export type PortableKind = keyof typeof PORTABLE_KIND_TO_ID_FIELD
4546

4647
/**
4748
* Matches a portable chip markdown link: `[label](sim:kind/id)`.
48-
* - group 1: label (any non-`]` chars)
49+
* - group 1: label (plain or backslash-escaped characters)
4950
* - group 2: kind (lowercase letters / underscores, e.g. `past_chat`)
5051
* - group 3: id (any non-`)` / non-whitespace chars)
5152
*/
5253
const CHIP_LINK_PATTERN = new RegExp(
53-
`\\[([^\\]]+)\\]\\(${CHIP_LINK_SCHEME}:([a-z_]+)\\/([^)\\s]+)\\)`,
54+
`\\[((?:\\\\.|[^\\]\\\\])+)\\]\\(${SIM_LINK_SCHEME}:([a-z_]+)\\/([^)\\s]+)\\)`,
5455
'g'
5556
)
5657

@@ -96,7 +97,7 @@ function serializeChipContext(context: ChatContext): string | null {
9697
if (!isPortableKind(context.kind)) return null
9798
const id = getPortableId(context)
9899
if (!id) return null
99-
return `[${context.label}](${CHIP_LINK_SCHEME}:${context.kind}/${id})`
100+
return toSimMarkdownLink(context.kind, id, context.label)
100101
}
101102

102103
/**
@@ -205,7 +206,7 @@ export function parseChipLinks(text: string): ParsedChipLink[] {
205206
links.push({
206207
kind,
207208
id,
208-
label,
209+
label: fromSimMarkdownLabel(label),
209210
start: match.index,
210211
end: match.index + full.length,
211212
})

0 commit comments

Comments
 (0)