Skip to content

Commit 52154e8

Browse files
committed
fix(chat): copy workspace resources as portable links
1 parent 8696252 commit 52154e8

20 files changed

Lines changed: 583 additions & 103 deletions

File tree

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ChipModalField,
99
ChipModalFooter,
1010
ChipModalHeader,
11+
type ClipboardContent,
1112
cn,
1213
Duplicate,
1314
Split,
@@ -32,7 +33,7 @@ interface MessageActionsProps {
3233
content: string
3334
getCopyContent?: () => string
3435
hasCopyContent?: boolean
35-
prepareContentForCopy?: (content: string) => string
36+
prepareContentForCopy?: (content: string) => ClipboardContent
3637
userQuery?: string
3738
requestId?: string
3839
messageId?: string
@@ -70,7 +71,7 @@ export const MessageActions = memo(function MessageActions({
7071
const contentToCopy = getCopyContent?.() ?? content
7172
if (!contentToCopy) return
7273
const markdown = prepareContentForCopy?.(contentToCopy) ?? contentToCopy
73-
if (!markdown) return
74+
if (typeof markdown === 'string' && !markdown) return
7475
void copyMessage(markdown)
7576
}
7677

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ import {
1111
} from '@tiptap/extension-table'
1212
import { Markdown } from '@tiptap/markdown'
1313
import StarterKit from '@tiptap/starter-kit'
14+
import { SIM_LINK_SCHEME } from '@/lib/copilot/sim-link'
1415
import { MarkdownCodeBlock } from './code-block-schema'
1516
import { Highlight } from './highlight'
1617
import { MarkdownImage } from './image-schema'
1718
import { MarkdownLinkInputRule } from './link-input-rule'
1819
import { MarkdownMention } from './mention/mention-node'
19-
import { SIM_LINK_SCHEME } from './mention/sim-link'
2020
import {
2121
FootnoteDef,
2222
FootnoteRef,
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
export { SIM_LINK_SCHEME, toSimHref } from '@/lib/copilot/sim-link'
12
export { MENTION_PLUGIN_KEY, Mention, type MentionStorage } from './mention'
23
export { MentionChip } from './mention-chip'
34
export { MarkdownMention } from './mention-node'
4-
export { SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link'
5+
export { simLinkPath } from './sim-link'
56
export type { MentionItem, MentionKind } from './types'
67
export { useEditorMentions } from './use-editor-mentions'
78
export { useMarkdownMentions } from './use-markdown-mentions'

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ describe('mention node round-trip', () => {
4444
expect(serializeMarkdownBody(input).trim()).toBe(input)
4545
})
4646

47+
it('round-trips a file reference containing whitespace and a closing parenthesis', () => {
48+
const input = '[Q1 plan](sim:file/files/Q1%20plan%29.md)'
49+
const doc = parseMarkdownToDoc(input)
50+
const mention = findMention(doc)
51+
expect(mention?.attrs).toEqual({ kind: 'file', id: 'files/Q1 plan).md', label: 'Q1 plan' })
52+
expect(serializeMarkdownBody(input).trim()).toBe(input)
53+
})
54+
4755
it('leaves a normal http link as a link, not a mention', () => {
4856
const doc = parseMarkdownToDoc('[Sim](https://sim.ai)')
4957
expect(findMention(doc)).toBeNull()

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

Lines changed: 13 additions & 15 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 { fromSimHrefId, fromSimMarkdownLabel, toSimMarkdownLink } from '@/lib/copilot/sim-link'
44
import type { MentionKind } from './types'
55

66
export interface MentionAttrs {
@@ -16,16 +16,6 @@ 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. */
25-
function unescapeLabel(label: string): string {
26-
return label.replace(/\\([\\[\]])/g, '$1')
27-
}
28-
2919
/** Custom fields the mention tokenizer hangs on the marked token (all optional, like the image token). */
3020
interface MentionTokenFields {
3121
label?: string
@@ -91,17 +81,21 @@ export const MarkdownMention = Node.create({
9181
const { kind, id, label } = token as MentionTokenFields
9282
return {
9383
type: 'mention',
94-
attrs: { kind: kind ?? '', id: id ?? '', label: unescapeLabel(label ?? '') },
84+
attrs: {
85+
kind: kind ?? '',
86+
id: fromSimHrefId(id ?? ''),
87+
label: fromSimMarkdownLabel(label ?? ''),
88+
},
9589
}
9690
},
9791
renderMarkdown: (node: JSONContent): string => {
9892
const { kind, id, label } = (node.attrs ?? {}) as MentionAttrs
99-
return `[${escapeLabel(label)}](${toSimHref(kind, id)})`
93+
return toSimMarkdownLink(kind, id, label)
10094
},
10195

10296
renderText: ({ node }) => {
10397
const { kind, id, label } = node.attrs as MentionAttrs
104-
return `[${escapeLabel(label)}](${toSimHref(kind, id)})`
98+
return toSimMarkdownLink(kind, id, label)
10599
},
106100

107101
/**
@@ -123,7 +117,11 @@ export const MarkdownMention = Node.create({
123117
state.tr.replaceWith(
124118
range.from,
125119
range.to,
126-
type.create({ kind, id, label: unescapeLabel(rawLabel ?? '') })
120+
type.create({
121+
kind,
122+
id: fromSimHrefId(id),
123+
label: fromSimMarkdownLabel(rawLabel ?? ''),
124+
})
127125
)
128126
},
129127
}),

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import { describe, expect, it } from 'vitest'
2+
import { fromSimHrefId, toSimHref } from '@/lib/copilot/sim-link'
23
import { simLinkPath } from './sim-link'
34

5+
describe('sim link id codec', () => {
6+
it('round-trips identifiers containing link delimiters', () => {
7+
const id = 'files/Q1 plan).md'
8+
const href = toSimHref('file', id)
9+
10+
expect(href).toBe('sim:file/files/Q1%20plan%29.md')
11+
expect(fromSimHrefId(href.slice('sim:file/'.length))).toBe(id)
12+
})
13+
14+
it('leaves malformed percent encoding intact', () => {
15+
expect(fromSimHrefId('file%2')).toBe('file%2')
16+
})
17+
})
18+
419
describe('simLinkPath', () => {
520
const ws = 'ws1'
621

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

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,3 @@
1-
/**
2-
* The link scheme for `@`-mention links — `[label](sim:<kind>/<id>)`. Matches the chat composer's
3-
* portable chip format (`chip-clipboard-codec.ts`), so a mention authored here is parseable there.
4-
*/
5-
export const SIM_LINK_SCHEME = 'sim'
6-
7-
/** Builds the link target for a mention of `kind`/`id`. */
8-
export function toSimHref(kind: string, id: string): string {
9-
return `${SIM_LINK_SCHEME}:${kind}/${id}`
10-
}
11-
121
/**
132
* Resolves the in-app route for a clicked `sim:` mention, or `null` when the kind has no navigable
143
* 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/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+
export 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+
}

0 commit comments

Comments
 (0)