Skip to content

Commit 941f44d

Browse files
committed
fix(chat): encode portable link ids
1 parent 851e2f0 commit 941f44d

6 files changed

Lines changed: 69 additions & 8 deletions

File tree

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

66
export interface MentionAttrs {
@@ -81,7 +81,11 @@ export const MarkdownMention = Node.create({
8181
const { kind, id, label } = token as MentionTokenFields
8282
return {
8383
type: 'mention',
84-
attrs: { kind: kind ?? '', id: id ?? '', label: fromSimMarkdownLabel(label ?? '') },
84+
attrs: {
85+
kind: kind ?? '',
86+
id: fromSimHrefId(id ?? ''),
87+
label: fromSimMarkdownLabel(label ?? ''),
88+
},
8589
}
8690
},
8791
renderMarkdown: (node: JSONContent): string => {
@@ -113,7 +117,11 @@ export const MarkdownMention = Node.create({
113117
state.tr.replaceWith(
114118
range.from,
115119
range.to,
116-
type.create({ kind, id, label: fromSimMarkdownLabel(rawLabel ?? '') })
120+
type.create({
121+
kind,
122+
id: fromSimHrefId(id),
123+
label: fromSimMarkdownLabel(rawLabel ?? ''),
124+
})
117125
)
118126
},
119127
}),

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
import { describe, expect, it } from 'vitest'
2-
import { simLinkPath } from './sim-link'
2+
import { fromSimHrefId, simLinkPath, toSimHref } from './sim-link'
3+
4+
describe('sim link id codec', () => {
5+
it('round-trips identifiers containing link delimiters', () => {
6+
const id = 'files/Q1 plan).md'
7+
const href = toSimHref('file', id)
8+
9+
expect(href).toBe('sim:file/files/Q1%20plan%29.md')
10+
expect(fromSimHrefId(href.slice('sim:file/'.length))).toBe(id)
11+
})
12+
13+
it('leaves malformed percent encoding intact', () => {
14+
expect(fromSimHrefId('file%2')).toBe('file%2')
15+
})
16+
})
317

418
describe('simLinkPath', () => {
519
const ws = 'ws1'

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ export const SIM_LINK_SCHEME = 'sim'
66

77
/** Builds the link target for a mention of `kind`/`id`. */
88
export function toSimHref(kind: string, id: string): string {
9-
return `${SIM_LINK_SCHEME}:${kind}/${id}`
9+
const encodedId = encodeURIComponent(id)
10+
.replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`)
11+
.replace(/%2F/gi, '/')
12+
return `${SIM_LINK_SCHEME}:${kind}/${encodedId}`
1013
}
1114

1215
/** Builds portable mention Markdown while escaping characters that can break its label. */
@@ -20,6 +23,15 @@ export function fromSimMarkdownLabel(label: string): string {
2023
return label.replace(/\\([\\[\]])/g, '$1')
2124
}
2225

26+
/** Restores an identifier serialized into a `sim:` link without throwing on malformed input. */
27+
export function fromSimHrefId(id: string): string {
28+
try {
29+
return decodeURIComponent(id)
30+
} catch {
31+
return id
32+
}
33+
}
34+
2335
/**
2436
* Resolves the in-app route for a clicked `sim:` mention, or `null` when the kind has no navigable
2537
* 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: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,15 +127,15 @@ describe('toCopyableMarkdown', () => {
127127
expect(content).not.toBeTypeOf('string')
128128
if (typeof content === 'string') throw new Error('Expected deferred clipboard content')
129129
expect(content.fallback).toBe(
130-
'Read [The Bell at Low Tide.md](sim:file/files/The%20Bell%20at%20Low%20Tide.md).'
130+
'Read [The Bell at Low Tide.md](sim:file/files/The%2520Bell%2520at%2520Low%2520Tide.md).'
131131
)
132132
expect(parseChipLinks(content.fallback)).toEqual([
133133
{
134134
kind: 'file',
135135
id: 'files/The%20Bell%20at%20Low%20Tide.md',
136136
label: 'The Bell at Low Tide.md',
137137
start: 5,
138-
end: 78,
138+
end: 86,
139139
},
140140
])
141141
await expect(content.prepare()).resolves.toBe(
@@ -144,6 +144,24 @@ describe('toCopyableMarkdown', () => {
144144
expect(refreshWorkspaceFiles).toHaveBeenCalledOnce()
145145
})
146146

147+
it('escapes unsafe characters in unresolved file references', () => {
148+
const message =
149+
'Read <workspace_resource>{"type":"file","path":"files/Q1 plan).md","title":"Q1 plan).md"}</workspace_resource>.'
150+
151+
const { markdown } = serializeCopyableMarkdown(message)
152+
153+
expect(markdown).toBe('Read [Q1 plan).md](sim:file/files/Q1%20plan%29.md).')
154+
expect(parseChipLinks(markdown)).toEqual([
155+
{
156+
kind: 'file',
157+
id: 'files/Q1 plan).md',
158+
label: 'Q1 plan).md',
159+
start: 5,
160+
end: 50,
161+
},
162+
])
163+
})
164+
147165
it('does not refresh metadata when all workspace resources already resolve', () => {
148166
const message =
149167
'Read <workspace_resource>{"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}</workspace_resource>.'

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
fromSimHrefId,
23
fromSimMarkdownLabel,
34
SIM_LINK_SCHEME,
45
toSimMarkdownLink,
@@ -205,7 +206,7 @@ export function parseChipLinks(text: string): ParsedChipLink[] {
205206
if (!isPortableKind(kind)) continue
206207
links.push({
207208
kind,
208-
id,
209+
id: fromSimHrefId(id),
209210
label: fromSimMarkdownLabel(label),
210211
start: match.index,
211212
end: match.index + full.length,

0 commit comments

Comments
 (0)