Skip to content

Commit bb68df9

Browse files
committed
feat(rich-editor): render mentions as icon chips + menu/limit polish
- Render @ mentions as an inline chip node (entity icon + label) instead of a blue link; still serializes to the portable [label](sim:kind/id) markdown so it round-trips and stays agent-readable (shared mentionIcon resolver) - Cap the mention/slash menu height + width and scroll it, matching the chat menu - Give the version description editor more height; lift the 2000-char limit to a high anti-abuse cap (client + contract) and drop the visible counter
1 parent b393ea9 commit bb68df9

12 files changed

Lines changed: 228 additions & 51 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { MarkdownImage, ResizableImage } from './image'
1717
import { RichMarkdownKeymap } from './keymap'
1818
import { MarkdownLinkInputRule } from './link-input-rule'
1919
import { MarkdownPaste } from './markdown-paste'
20-
import { Mention, SIM_LINK_SCHEME } from './mention'
20+
import { MarkdownMention, Mention, MentionChip, SIM_LINK_SCHEME } from './mention'
2121
import { SlashCommand } from './slash-command/slash-command'
2222

2323
/**
@@ -94,6 +94,7 @@ export function createMarkdownContentExtensions({
9494
InlineCode,
9595
codeBlock,
9696
(nodeViews ? ResizableImage : MarkdownImage).configure({ allowBase64: true }),
97+
nodeViews ? MentionChip : MarkdownMention,
9798
TaskList,
9899
TaskItem.configure({ nested: true }),
99100
PipeSafeTable.configure({ resizable: true }),

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { Mention, type MentionStorage } from './mention'
2+
export { MarkdownMention, MentionChip } from './mention-node'
23
export { parseSimHref, SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link'
34
export type { MentionItem, MentionKind } from './types'
45
export { useEditorMentions } from './use-editor-mentions'
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { ComponentType } from 'react'
2+
import { Database, File, Folder, Sparkles, Table, Workflow } from 'lucide-react'
3+
import { getBlock } from '@/blocks/registry'
4+
import type { MentionKind } from './types'
5+
6+
/** Icon component shape both the lucide kind-icons and the brand block icons satisfy. */
7+
export type MentionIcon = ComponentType<{ className?: string }>
8+
9+
const KIND_ICONS: Record<Exclude<MentionKind, 'integration'>, MentionIcon> = {
10+
file: File,
11+
folder: Folder,
12+
table: Table,
13+
knowledge: Database,
14+
workflow: Workflow,
15+
skill: Sparkles,
16+
}
17+
18+
/**
19+
* Resolves the icon for a mention. Integrations use their brand icon from the block registry (keyed by
20+
* blockType, which is the mention `id`); every other kind uses a lucide category icon. Shared by the
21+
* menu rows and the inserted chip so both render the same icon.
22+
*/
23+
export function mentionIcon(kind: MentionKind, id: string): MentionIcon | undefined {
24+
if (kind === 'integration') return getBlock(id)?.icon as MentionIcon | undefined
25+
return KIND_ICONS[kind]
26+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* The `@`-mention is stored as a portable `[label](sim:<kind>/<id>)` markdown link but parses into a
5+
* dedicated `mention` node (rendered live as a chip). These guard that the parse → node → serialize
6+
* cycle is lossless, so the chat-portable wire format and the chip rendering stay in sync.
7+
*/
8+
import type { JSONContent } from '@tiptap/core'
9+
import { describe, expect, it } from 'vitest'
10+
import { parseMarkdownToDoc, serializeMarkdownBody } from '../markdown-parse'
11+
12+
function findMention(node: JSONContent): JSONContent | null {
13+
if (node.type === 'mention') return node
14+
for (const child of node.content ?? []) {
15+
const found = findMention(child)
16+
if (found) return found
17+
}
18+
return null
19+
}
20+
21+
describe('mention node round-trip', () => {
22+
it('parses a sim: link into a mention node with kind/id/label', () => {
23+
const doc = parseMarkdownToDoc('See [Airweave](sim:integration/airweave) here')
24+
const mention = findMention(doc)
25+
expect(mention).not.toBeNull()
26+
expect(mention?.attrs).toEqual({ kind: 'integration', id: 'airweave', label: 'Airweave' })
27+
})
28+
29+
it('serializes a mention node back to the portable sim: link', () => {
30+
for (const input of [
31+
'See [Airweave](sim:integration/airweave) here',
32+
'[my-skill](sim:skill/abc-123)',
33+
'a [Spec.md](sim:file/xyz_789) b',
34+
]) {
35+
expect(serializeMarkdownBody(input).trim()).toBe(input)
36+
}
37+
})
38+
39+
it('leaves a normal http link as a link, not a mention', () => {
40+
const doc = parseMarkdownToDoc('[Sim](https://sim.ai)')
41+
expect(findMention(doc)).toBeNull()
42+
})
43+
})
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type { MouseEvent } from 'react'
2+
import type { JSONContent, MarkdownToken } from '@tiptap/core'
3+
import { Node } from '@tiptap/core'
4+
import type { ReactNodeViewProps } from '@tiptap/react'
5+
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
6+
import { useParams, useRouter } from 'next/navigation'
7+
import { cn } from '@/lib/core/utils/cn'
8+
import { mentionIcon } from './mention-icon'
9+
import { simLinkPath, toSimHref } from './sim-link'
10+
import type { MentionKind } from './types'
11+
12+
interface MentionAttrs {
13+
kind: MentionKind
14+
id: string
15+
label: string
16+
}
17+
18+
/** The markdown form of a mention — the chat's portable `[label](sim:<kind>/<id>)` link. */
19+
const MENTION_MD_RE = /^\[([^\]]+)\]\(sim:([a-z_]+)\/([^)\s]+)\)/
20+
21+
/** Custom fields the mention tokenizer hangs on the marked token (all optional, like the image token). */
22+
interface MentionTokenFields {
23+
label?: string
24+
kind?: string
25+
id?: string
26+
}
27+
28+
/**
29+
* Inline atom node for an `@`-mention. Renders (live) as a chip with the entity's icon, but serializes
30+
* to the portable `[label](sim:<kind>/<id>)` markdown link — so the saved content is identical to a
31+
* plain link (agent-readable, round-trips through the chat's `chip-clipboard-codec`) while the editor
32+
* shows it as a chip rather than a blue link. Shared by the headless round-trip path (no node view)
33+
* and the live {@link MentionChip}, mirroring the image node's split.
34+
*/
35+
export const MarkdownMention = Node.create({
36+
name: 'mention',
37+
inline: true,
38+
group: 'inline',
39+
atom: true,
40+
selectable: true,
41+
draggable: false,
42+
43+
addAttributes() {
44+
return {
45+
kind: { default: '' },
46+
id: { default: '' },
47+
label: { default: '' },
48+
}
49+
},
50+
51+
parseHTML() {
52+
return [
53+
{
54+
tag: 'span[data-mention]',
55+
getAttrs: (element) => ({
56+
kind: element.getAttribute('data-kind') ?? '',
57+
id: element.getAttribute('data-id') ?? '',
58+
label: element.textContent ?? '',
59+
}),
60+
},
61+
]
62+
},
63+
64+
renderHTML({ node }) {
65+
const { kind, id, label } = node.attrs as MentionAttrs
66+
return ['span', { 'data-mention': '', 'data-kind': kind, 'data-id': id }, label]
67+
},
68+
69+
markdownTokenizer: {
70+
name: 'mention',
71+
level: 'inline' as const,
72+
start: (src: string) => src.indexOf('['),
73+
tokenize: (src: string): (MentionTokenFields & { type: string; raw: string }) | undefined => {
74+
const match = MENTION_MD_RE.exec(src)
75+
if (!match) return undefined
76+
return { type: 'mention', raw: match[0], label: match[1], kind: match[2], id: match[3] }
77+
},
78+
},
79+
parseMarkdown: (token: MarkdownToken): JSONContent => {
80+
const { kind, id, label } = token as MentionTokenFields
81+
return { type: 'mention', attrs: { kind: kind ?? '', id: id ?? '', label: label ?? '' } }
82+
},
83+
renderMarkdown: (node: JSONContent): string => {
84+
const { kind, id, label } = (node.attrs ?? {}) as MentionAttrs
85+
return `[${label}](${toSimHref(kind, id)})`
86+
},
87+
})
88+
89+
const CHIP_CLASS =
90+
'mx-px inline-flex items-center gap-1 rounded-[4px] bg-[var(--surface-4)] px-1 align-middle font-medium text-[var(--text-primary)] leading-[1.5] cursor-pointer select-none [&>svg]:size-[14px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]'
91+
92+
/** Live chip: the entity icon + label. Cmd/Ctrl-click navigates to the resource. */
93+
function MentionChipView({ node }: ReactNodeViewProps) {
94+
const router = useRouter()
95+
const params = useParams()
96+
const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined
97+
const { kind, id, label } = node.attrs as MentionAttrs
98+
const Icon = mentionIcon(kind, id)
99+
100+
const handleClick = (event: MouseEvent) => {
101+
if (!(event.metaKey || event.ctrlKey) || !workspaceId) return
102+
const path = simLinkPath(workspaceId, kind, id)
103+
if (!path) return
104+
event.preventDefault()
105+
router.push(path)
106+
}
107+
108+
return (
109+
<NodeViewWrapper as='span' className={cn(CHIP_CLASS)} onClick={handleClick} title={label}>
110+
{Icon && <Icon />}
111+
<span>{label}</span>
112+
</NodeViewWrapper>
113+
)
114+
}
115+
116+
/** Live mention node with the chip view; same schema + markdown output as the headless one. */
117+
export const MentionChip = MarkdownMention.extend({
118+
addNodeView() {
119+
return ReactNodeViewRenderer(MentionChipView)
120+
},
121+
})

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import Suggestion from '@tiptap/suggestion'
44
import { createSuggestionPopupRenderer } from '../menus/suggestion-popup'
55
import { MentionList } from './mention-list'
66
import { createMentionStore, type MentionStore } from './mention-store'
7-
import { toSimHref } from './sim-link'
87
import type { MentionItem } from './types'
98

109
/** Distinct from the `/` slash command's default `suggestion` key — two plugins can't share one key. */
@@ -63,13 +62,12 @@ export const Mention = Extension.create<Record<string, never>, MentionStorage>({
6362
// Items are sourced reactively from the store inside MentionList; this only gates the plugin.
6463
items: () => [],
6564
command: ({ editor, range, props }) => {
66-
const href = toSimHref(props.kind, props.id)
6765
editor
6866
.chain()
6967
.focus()
7068
.deleteRange(range)
7169
.insertContent([
72-
{ type: 'text', text: props.label, marks: [{ type: 'link', attrs: { href } }] },
70+
{ type: 'mention', attrs: { kind: props.kind, id: props.id, label: props.label } },
7371
{ type: 'text', text: ' ' },
7472
])
7573
.run()

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

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { useMemo } from 'react'
2-
import { Database, File, Folder, Sparkles, Table, Workflow } from 'lucide-react'
32
import { listIntegrations } from '@/blocks/integration-matcher'
43
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
54
import { useSkills } from '@/hooks/queries/skills'
65
import { useTablesList } from '@/hooks/queries/tables'
76
import { useWorkflows } from '@/hooks/queries/workflows'
87
import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
98
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
9+
import { mentionIcon } from './mention-icon'
1010
import type { MentionItem } from './types'
1111

1212
/**
@@ -41,7 +41,7 @@ export function useMarkdownMentions(
4141
id: integration.blockType,
4242
label: integration.name,
4343
group: 'Integrations',
44-
icon: integration.icon,
44+
icon: mentionIcon('integration', integration.blockType),
4545
}))
4646
}, [active])
4747

@@ -50,40 +50,52 @@ export function useMarkdownMentions(
5050
const items: MentionItem[] = []
5151

5252
for (const file of files.data ?? [])
53-
items.push({ kind: 'file', id: file.id, label: file.name, group: 'Files', icon: File })
53+
items.push({
54+
kind: 'file',
55+
id: file.id,
56+
label: file.name,
57+
group: 'Files',
58+
icon: mentionIcon('file', file.id),
59+
})
5460
for (const folder of folders.data ?? [])
5561
items.push({
5662
kind: 'folder',
5763
id: folder.id,
5864
label: folder.name,
5965
group: 'Folders',
60-
icon: Folder,
66+
icon: mentionIcon('folder', folder.id),
6167
})
6268
for (const table of tables.data ?? [])
63-
items.push({ kind: 'table', id: table.id, label: table.name, group: 'Tables', icon: Table })
69+
items.push({
70+
kind: 'table',
71+
id: table.id,
72+
label: table.name,
73+
group: 'Tables',
74+
icon: mentionIcon('table', table.id),
75+
})
6476
for (const kb of knowledgeBases.data ?? [])
6577
items.push({
6678
kind: 'knowledge',
6779
id: kb.id,
6880
label: kb.name,
6981
group: 'Knowledge bases',
70-
icon: Database,
82+
icon: mentionIcon('knowledge', kb.id),
7183
})
7284
for (const workflow of workflows.data ?? [])
7385
items.push({
7486
kind: 'workflow',
7587
id: workflow.id,
7688
label: workflow.name,
7789
group: 'Workflows',
78-
icon: Workflow,
90+
icon: mentionIcon('workflow', workflow.id),
7991
})
8092
for (const skill of skills.data ?? [])
8193
items.push({
8294
kind: 'skill',
8395
id: skill.id,
8496
label: skill.name,
8597
group: 'Skills',
86-
icon: Sparkles,
98+
icon: mentionIcon('skill', skill.id),
8799
})
88100
items.push(...integrationItems)
89101

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
* class strings per consumer.
55
*/
66

7-
/** The floating panel: bordered card with the enter animation. */
7+
/** The floating panel: bordered card with the enter animation, width-capped like the chat mention menu. */
88
export const SUGGESTION_SURFACE_CLASS =
9-
'min-w-[220px] origin-top-left animate-in rounded-xl border border-[var(--border)] bg-[var(--bg)] p-1.5 shadow-sm duration-100 fade-in-0 zoom-in-95 slide-in-from-top-2 motion-reduce:animate-none'
9+
'min-w-[220px] max-w-[min(300px,calc(100vw-32px))] origin-top-left animate-in rounded-xl border border-[var(--border)] bg-[var(--bg)] p-1.5 shadow-sm duration-100 fade-in-0 zoom-in-95 slide-in-from-top-2 motion-reduce:animate-none'
1010

11-
/** A scrollable list body (hidden scrollbar), added alongside {@link SUGGESTION_SURFACE_CLASS}. */
12-
export const SUGGESTION_SCROLL_CLASS =
13-
'max-h-[330px] scroll-py-1.5 overflow-y-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
11+
/**
12+
* A scrollable list body, added alongside {@link SUGGESTION_SURFACE_CLASS}. Caps the height and scrolls
13+
* — matching the chat composer's `@` menu — so a long workspace list never overflows its container.
14+
*/
15+
export const SUGGESTION_SCROLL_CLASS = 'max-h-[240px] scroll-py-1.5 overflow-y-auto overscroll-none'
1416

1517
/** A selectable row: icon + label, 14px icon in `--text-icon`, truncating label. */
1618
export const SUGGESTION_ITEM_CLASS =

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

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
splitFrontmatter,
2222
} from './markdown-fidelity'
2323
import { parseMarkdownToDoc } from './markdown-parse'
24-
import { parseSimHref, simLinkPath, useEditorMentions } from './mention'
24+
import { useEditorMentions } from './mention'
2525
import { EditorBubbleMenu } from './menus/bubble-menu'
2626
import { LinkHoverCard } from './menus/link-hover-card'
2727
import { normalizeMarkdownContent } from './normalize-content'
@@ -261,14 +261,6 @@ export function LoadedRichMarkdownEditor({
261261
})
262262
return true
263263
}
264-
// A `@`-mention link (`sim:<kind>/<id>`) navigates to the referenced resource in-app.
265-
if (href.startsWith('sim:')) {
266-
const parsed = parseSimHref(href)
267-
const path = parsed && simLinkPath(workspaceId, parsed.kind, parsed.id)
268-
if (!path) return false
269-
routerRef.current.push(path)
270-
return true
271-
}
272264
const normalized = normalizeLinkHref(href)
273265
if (!normalized) return false
274266
// A same-origin in-app path navigates within the SPA (same tab); external URLs open a new tab.

0 commit comments

Comments
 (0)