Skip to content

Commit e7ffc07

Browse files
committed
refactor(chat): simplify portable resource copying
1 parent 52154e8 commit e7ffc07

16 files changed

Lines changed: 162 additions & 308 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ export const MessageActions = memo(function MessageActions({
7070
const copyToClipboard = () => {
7171
const contentToCopy = getCopyContent?.() ?? content
7272
if (!contentToCopy) return
73-
const markdown = prepareContentForCopy?.(contentToCopy) ?? contentToCopy
74-
if (typeof markdown === 'string' && !markdown) return
75-
void copyMessage(markdown)
73+
const copyContent = prepareContentForCopy?.(contentToCopy) ?? contentToCopy
74+
if (typeof copyContent === 'string' && !copyContent) return
75+
void copyMessage(copyContent)
7676
}
7777

7878
const copyRequestId = async () => {

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'
1514
import { MarkdownCodeBlock } from './code-block-schema'
1615
import { Highlight } from './highlight'
1716
import { MarkdownImage } from './image-schema'
1817
import { MarkdownLinkInputRule } from './link-input-rule'
1918
import { MarkdownMention } from './mention/mention-node'
19+
import { SIM_LINK_SCHEME } from './mention/sim-link'
2020
import {
2121
FootnoteDef,
2222
FootnoteRef,
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
export { SIM_LINK_SCHEME, toSimHref } from '@/lib/copilot/sim-link'
21
export { MENTION_PLUGIN_KEY, Mention, type MentionStorage } from './mention'
32
export { MentionChip } from './mention-chip'
43
export { MarkdownMention } from './mention-node'
5-
export { simLinkPath } from './sim-link'
4+
export { SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link'
65
export type { MentionItem, MentionKind } from './types'
76
export { useEditorMentions } from './use-editor-mentions'
87
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: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,6 @@ 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-
5547
it('leaves a normal http link as a link, not a mention', () => {
5648
const doc = parseMarkdownToDoc('[Sim](https://sim.ai)')
5749
expect(findMention(doc)).toBeNull()

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

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

66
export interface MentionAttrs {
@@ -16,6 +16,16 @@ 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+
1929
/** Custom fields the mention tokenizer hangs on the marked token (all optional, like the image token). */
2030
interface MentionTokenFields {
2131
label?: string
@@ -81,21 +91,17 @@ export const MarkdownMention = Node.create({
8191
const { kind, id, label } = token as MentionTokenFields
8292
return {
8393
type: 'mention',
84-
attrs: {
85-
kind: kind ?? '',
86-
id: fromSimHrefId(id ?? ''),
87-
label: fromSimMarkdownLabel(label ?? ''),
88-
},
94+
attrs: { kind: kind ?? '', id: id ?? '', label: unescapeLabel(label ?? '') },
8995
}
9096
},
9197
renderMarkdown: (node: JSONContent): string => {
9298
const { kind, id, label } = (node.attrs ?? {}) as MentionAttrs
93-
return toSimMarkdownLink(kind, id, label)
99+
return `[${escapeLabel(label)}](${toSimHref(kind, id)})`
94100
},
95101

96102
renderText: ({ node }) => {
97103
const { kind, id, label } = node.attrs as MentionAttrs
98-
return toSimMarkdownLink(kind, id, label)
104+
return `[${escapeLabel(label)}](${toSimHref(kind, id)})`
99105
},
100106

101107
/**
@@ -117,11 +123,7 @@ export const MarkdownMention = Node.create({
117123
state.tr.replaceWith(
118124
range.from,
119125
range.to,
120-
type.create({
121-
kind,
122-
id: fromSimHrefId(id),
123-
label: fromSimMarkdownLabel(rawLabel ?? ''),
124-
})
126+
type.create({ kind, id, label: unescapeLabel(rawLabel ?? '') })
125127
)
126128
},
127129
}),

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

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

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-
194
describe('simLinkPath', () => {
205
const ws = 'ws1'
216

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
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+
112
/**
213
* Resolves the in-app route for a clicked `sim:` mention, or `null` when the kind has no navigable
314
* 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: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,6 @@ 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'
2218
import {
2319
type ContentSegment,
2420
type CredentialSubmissionPayload,
@@ -99,6 +95,47 @@ const ANIMATION_DRAIN_MS = 300
9995
*/
10096
const FADE_MAX_REVEALED_CHARS = 6000
10197

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+
102139
type TdProps = ComponentPropsWithoutRef<'td'>
103140
type ThProps = ComponentPropsWithoutRef<'th'>
104141

@@ -549,9 +586,14 @@ function ChatContentInner({
549586
const s = parsed.segments[i]
550587
const nextSegment = parsed.segments[i + 1]
551588
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
552594
pendingMarkdown = appendInlineReferenceMarkdown(
553595
pendingMarkdown,
554-
workspaceResourceReferenceMarkdown(s.data),
596+
`[${label}](<#wsres-${s.data.type}-${ref}>)`,
555597
nextSegment
556598
)
557599
} else if (s.type === 'thinking') {

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

Lines changed: 0 additions & 54 deletions
This file was deleted.

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

Lines changed: 2 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,10 @@
11
import { describe, expect, it, vi } from 'vitest'
2-
3-
vi.mock('@/lib/auth/auth-client', () => ({
4-
useSession: vi.fn(() => ({ data: null, isPending: false })),
5-
}))
6-
72
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
8-
import { getOrchestratorMessageText } from '@/app/workspace/[workspaceId]/home/components/message-content'
93
import {
104
prepareCopyableMarkdown,
11-
serializeCopyableMarkdown,
125
toCopyableMarkdown,
136
} from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
147
import { parseChipLinks } from '@/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec'
15-
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
168

179
const WORKSPACE_FILES: WorkspaceFileRecord[] = [
1810
{
@@ -83,7 +75,7 @@ describe('toCopyableMarkdown', () => {
8375
id: 'tbl_f26af6dae98d4222b014b250494d00fb',
8476
title: 'Checked_[rare]\\portal',
8577
})}</workspace_resource>.`,
86-
].join(' ')
78+
].join('')
8779

8880
const markdown = toCopyableMarkdown(message, WORKSPACE_FILES)
8981

@@ -117,32 +109,6 @@ describe('toCopyableMarkdown', () => {
117109
)
118110
})
119111

120-
it('uses cached names for workflow and table labels shown in the chat', () => {
121-
const message = [
122-
'<workspace_resource>{"type":"workflow","id":"workflow-1","title":"Old workflow name"}</workspace_resource>',
123-
'<workspace_resource>{"type":"table","id":"table-1"}</workspace_resource>',
124-
].join(' and ')
125-
126-
expect(
127-
toCopyableMarkdown(message, [], {
128-
workflow: new Map([['workflow-1', 'Current workflow name']]),
129-
table: new Map([['table-1', 'Current table name']]),
130-
})
131-
).toBe(
132-
'[Current workflow name](sim:workflow/workflow-1) and [Current table name](sim:table/table-1)'
133-
)
134-
})
135-
136-
it('reports file resources that need refreshed metadata before copying', () => {
137-
const message =
138-
'Read <workspace_resource>{"type":"file","path":"files/notes.md","title":"notes.md"}</workspace_resource>.'
139-
140-
expect(serializeCopyableMarkdown(message)).toEqual({
141-
markdown: 'Read notes.md.',
142-
hasUnresolvedFile: true,
143-
})
144-
})
145-
146112
it('refreshes missing file metadata before producing copyable Markdown', async () => {
147113
const message =
148114
'Read <workspace_resource>{"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}</workspace_resource>.'
@@ -163,7 +129,7 @@ describe('toCopyableMarkdown', () => {
163129
const message =
164130
'Read <workspace_resource>{"type":"file","path":"files/Q1 plan).md","title":"Q1 plan).md"}</workspace_resource>.'
165131

166-
const { markdown } = serializeCopyableMarkdown(message)
132+
const markdown = toCopyableMarkdown(message)
167133

168134
expect(markdown).toBe('Read Q1 plan).md.')
169135
expect(parseChipLinks(markdown)).toEqual([])
@@ -193,20 +159,4 @@ describe('toCopyableMarkdown', () => {
193159
)
194160
expect(refreshWorkspaceFiles).not.toHaveBeenCalled()
195161
})
196-
197-
it('copies workspace resources from orchestrator content blocks', () => {
198-
const contentBlocks: ContentBlock[] = [
199-
{ type: 'text', content: 'Read ' },
200-
{ type: 'thinking', content: 'Do not copy this.' },
201-
{
202-
type: 'text',
203-
content:
204-
'<workspace_resource>{"type":"file","path":"files/notes.md","title":"notes.md"}</workspace_resource> for details.',
205-
},
206-
]
207-
208-
const content = getOrchestratorMessageText(contentBlocks, 'Fallback without the resource.')
209-
210-
expect(toCopyableMarkdown(content, WORKSPACE_FILES)).toBe('Read notes.md for details.')
211-
})
212162
})

0 commit comments

Comments
 (0)