Skip to content

Commit 4d5111c

Browse files
committed
feat(rich-editor): rich markdown field + @ mentions for skill & deploy modals
- Add controlled, file-less RichMarkdownField (sibling of the file editor) used for skill Content and deploy version descriptions; placeholder/typography match chip fields - Add @-mention menu (TipTap suggestion) inserting portable [label](sim:kind/id) links; wired into the field and the file viewer via a shared useEditorMentions hook - Extract a shared suggestion-popup renderer + menu chrome (slash + mention) - Fix false dirty-on-open: normalize the editor's dirty baseline to canonical markdown - Always show the deployment version number (v3 · name) so named versions keep a short ref - Skill import: drop the paste box (Create-tab editor auto-destructures a pasted SKILL.md), reorder GitHub → Upload
1 parent 7ba0e23 commit 4d5111c

27 files changed

Lines changed: 1205 additions & 223 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* Regression guards for two bugs found while adding the `@` mention menu:
5+
*
6+
* 1. The `@` mention and `/` slash-command extensions each register a `@tiptap/suggestion` plugin.
7+
* They must use distinct plugin keys, or constructing any editor with the full set throws
8+
* "Adding different instances of a keyed plugin (suggestion$)".
9+
*
10+
* 2. A markdown file authored outside the editor (e.g. the former Monaco editor) is rarely in the
11+
* editor's canonical serialization. On open, a deferred view-plugin transaction re-serializes the
12+
* doc to canonical markdown and emits one update — which, compared against the raw saved bytes,
13+
* falsely marks the file dirty ("unsaved changes"). The fix normalizes the dirty-check baseline to
14+
* the canonical form; this asserts that normalized form equals what the live editor emits.
15+
*/
16+
import { Editor } from '@tiptap/core'
17+
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
18+
import { createMarkdownEditorExtensions } from './extensions'
19+
import {
20+
applyFrontmatter,
21+
postProcessSerializedMarkdown,
22+
splitFrontmatter,
23+
} from './markdown-fidelity'
24+
import { parseMarkdownToDoc } from './markdown-parse'
25+
import { normalizeMarkdownContent } from './normalize-content'
26+
27+
let editor: Editor | null = null
28+
let host: HTMLElement | null = null
29+
30+
beforeAll(() => {
31+
// jsdom lacks the layout APIs the Placeholder viewport plugin calls when a view mounts.
32+
// @ts-expect-error jsdom stub
33+
document.elementFromPoint = () => document.body
34+
// @ts-expect-error jsdom stub
35+
Range.prototype.getClientRects = () => [] as unknown as DOMRectList
36+
Range.prototype.getBoundingClientRect = () => new DOMRect()
37+
Element.prototype.getClientRects = () => [] as unknown as DOMRectList
38+
})
39+
40+
afterEach(() => {
41+
editor?.destroy()
42+
editor = null
43+
host?.remove()
44+
host = null
45+
})
46+
47+
describe('full extension set', () => {
48+
it('mounts without a duplicate suggestion-plugin-key error (@ and / coexist)', () => {
49+
expect(() => {
50+
editor = new Editor({
51+
extensions: createMarkdownEditorExtensions({ placeholder: 'x' }),
52+
content: '',
53+
})
54+
}).not.toThrow()
55+
})
56+
})
57+
58+
describe('normalizeMarkdownContent — dirty-on-open baseline', () => {
59+
it('normalizes non-canonical markdown to the editor canonical form', () => {
60+
expect(normalizeMarkdownContent('* one\n* two\n')).toBe('- one\n- two\n')
61+
})
62+
63+
it('is idempotent', () => {
64+
for (const md of [
65+
'* one\n* two\n',
66+
'| a | b |\n| --- | --- |\n| 1 | 2 |\n',
67+
'# H\n\nsome _emphasis_ here\n',
68+
]) {
69+
const once = normalizeMarkdownContent(md)
70+
expect(normalizeMarkdownContent(once)).toBe(once)
71+
}
72+
})
73+
74+
it('leaves round-trip-unsafe content untouched (read-only files keep their raw bytes)', () => {
75+
const unsafe = 'text with a footnote[^1]\n\n[^1]: the note\n'
76+
expect(normalizeMarkdownContent(unsafe)).toBe(unsafe)
77+
})
78+
})
79+
80+
describe('baseline neutralizes the mount-time dirty signal', () => {
81+
it('the editor mount serialization equals the normalized baseline (so isDirty stays false)', async () => {
82+
const raw = '# H\n\n* bullet\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n> quote\n'
83+
const { frontmatter, body } = splitFrontmatter(raw)
84+
host = document.createElement('div')
85+
document.body.appendChild(host)
86+
87+
let emitted: string | null = null
88+
editor = new Editor({
89+
element: host,
90+
extensions: createMarkdownEditorExtensions({ placeholder: 'x' }),
91+
content: parseMarkdownToDoc(body),
92+
onUpdate: ({ editor }) => {
93+
emitted = applyFrontmatter(frontmatter, postProcessSerializedMarkdown(editor.getMarkdown()))
94+
},
95+
})
96+
97+
await new Promise((resolve) => setTimeout(resolve, 30))
98+
99+
// The deferred mount transaction re-serializes to canonical markdown; the baseline must match it
100+
// exactly, so `content === savedContent` and the file is never falsely dirty on open.
101+
expect(emitted).not.toBeNull()
102+
expect(emitted).toBe(normalizeMarkdownContent(raw))
103+
})
104+
})

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,16 @@ 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'
2021
import { SlashCommand } from './slash-command/slash-command'
2122

23+
/**
24+
* The `@`-mention link scheme, registered on the Link mark — without it the schema strips the
25+
* `sim:<kind>/<id>` href on parse/round-trip, dropping the mention. `optionalSlashes` allows the
26+
* slash-less `sim:kind/id` form.
27+
*/
28+
const SIM_LINK_PROTOCOL = { scheme: SIM_LINK_SCHEME, optionalSlashes: true } as const
29+
2230
/**
2331
* Inline code that can combine with bold/italic/strike (GFM permits `**`x`**`, `~~`x`~~`).
2432
* The stock Code mark sets `excludes: '_'`, which blocks every other mark from coexisting and
@@ -78,7 +86,7 @@ export function createMarkdownContentExtensions({
7886
})
7987
return [
8088
StarterKit.configure({
81-
link: { openOnClick: false },
89+
link: { openOnClick: false, protocols: [SIM_LINK_PROTOCOL] },
8290
underline: false,
8391
codeBlock: false,
8492
code: false,
@@ -109,6 +117,7 @@ export function createMarkdownEditorExtensions({
109117
...createMarkdownContentExtensions({ nodeViews: true }),
110118
CodeBlockHighlight,
111119
SlashCommand,
120+
Mention,
112121
RichMarkdownKeymap,
113122
MarkdownPaste,
114123
Placeholder.configure({ placeholder }),
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export { Mention, type MentionStorage } from './mention'
2+
export { parseSimHref, SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link'
3+
export type { MentionItem, MentionKind } from './types'
4+
export { useEditorMentions } from './use-editor-mentions'
5+
export { useMarkdownMentions } from './use-markdown-mentions'
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import {
2+
forwardRef,
3+
useEffect,
4+
useImperativeHandle,
5+
useMemo,
6+
useRef,
7+
useState,
8+
useSyncExternalStore,
9+
} from 'react'
10+
import { cn } from '@/lib/core/utils/cn'
11+
import {
12+
SUGGESTION_GROUP_LABEL_CLASS,
13+
SUGGESTION_ITEM_CLASS,
14+
SUGGESTION_SCROLL_CLASS,
15+
SUGGESTION_SURFACE_CLASS,
16+
} from '../menus/suggestion-menu-chrome'
17+
import type { MentionStore } from './mention-store'
18+
import type { MentionItem } from './types'
19+
20+
export interface MentionListHandle {
21+
onKeyDown: (props: { event: KeyboardEvent }) => boolean
22+
}
23+
24+
interface MentionListProps {
25+
/** The text typed after `@`, used to filter. */
26+
query: string
27+
/** Inserts the chosen mention (wired to the suggestion `command`). */
28+
command: (item: MentionItem) => void
29+
/** Live data source the host keeps populated. */
30+
store: MentionStore
31+
}
32+
33+
/** Per-group cap so a large workspace can't flood the menu; filtering still searches the full set. */
34+
const MAX_PER_GROUP = 8
35+
36+
/** Category heading order in the menu. */
37+
const GROUP_ORDER = [
38+
'Files',
39+
'Folders',
40+
'Tables',
41+
'Knowledge bases',
42+
'Workflows',
43+
'Skills',
44+
'Integrations',
45+
] as const
46+
47+
/**
48+
* The `@` mention popup. Sibling of {@link SlashCommandList} with identical chrome and arrow/enter
49+
* navigation, but its items come reactively from the editor's {@link MentionStore} (via
50+
* `useSyncExternalStore`) rather than props — so the list fills in as async workspace data lands.
51+
*/
52+
export const MentionList = forwardRef<MentionListHandle, MentionListProps>(function MentionList(
53+
{ query, command, store },
54+
ref
55+
) {
56+
const rawItems = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
57+
const [activeIndex, setActiveIndex] = useState(0)
58+
const containerRef = useRef<HTMLDivElement>(null)
59+
60+
/** Filtered, group-capped, flattened in category order; `index` is the flat position for nav. */
61+
const { flat, groups } = useMemo(() => {
62+
const q = query.trim().toLowerCase()
63+
// One pass over the full set: filter by label and bucket by group (capped), then read the
64+
// buckets in category order — avoids a separate filter pass per group.
65+
const byGroup = new Map<string, MentionItem[]>()
66+
for (const item of rawItems) {
67+
if (q && !item.label.toLowerCase().includes(q)) continue
68+
const bucket = byGroup.get(item.group)
69+
if (!bucket) byGroup.set(item.group, [item])
70+
else if (bucket.length < MAX_PER_GROUP) bucket.push(item)
71+
}
72+
73+
const ordered: { group: string; items: { item: MentionItem; index: number }[] }[] = []
74+
const flat: MentionItem[] = []
75+
for (const group of GROUP_ORDER) {
76+
const inGroup = byGroup.get(group)
77+
if (!inGroup) continue
78+
ordered.push({ group, items: inGroup.map((item) => ({ item, index: flat.push(item) - 1 })) })
79+
}
80+
return { flat, groups: ordered }
81+
}, [rawItems, query])
82+
83+
useEffect(() => {
84+
setActiveIndex(0)
85+
}, [flat])
86+
87+
useEffect(() => {
88+
containerRef.current
89+
?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
90+
?.scrollIntoView({ block: 'nearest' })
91+
}, [activeIndex])
92+
93+
useImperativeHandle(ref, () => ({
94+
onKeyDown: ({ event }) => {
95+
if (flat.length === 0) return false
96+
if (event.key === 'ArrowUp') {
97+
setActiveIndex((i) => (i + flat.length - 1) % flat.length)
98+
return true
99+
}
100+
if (event.key === 'ArrowDown') {
101+
setActiveIndex((i) => (i + 1) % flat.length)
102+
return true
103+
}
104+
if (event.key === 'Enter') {
105+
const item = flat[activeIndex]
106+
if (!item) return false
107+
command(item)
108+
return true
109+
}
110+
return false
111+
},
112+
}))
113+
114+
if (flat.length === 0) {
115+
return (
116+
<div className={SUGGESTION_SURFACE_CLASS}>
117+
<p className='px-2 py-1.5 text-[var(--text-tertiary)] text-caption'>
118+
{rawItems.length === 0 ? 'Loading…' : 'No results'}
119+
</p>
120+
</div>
121+
)
122+
}
123+
124+
return (
125+
<div
126+
ref={containerRef}
127+
role='listbox'
128+
aria-label='Mentions'
129+
className={cn(SUGGESTION_SURFACE_CLASS, SUGGESTION_SCROLL_CLASS)}
130+
>
131+
{groups.map((group) => (
132+
<div key={group.group} role='group' aria-label={group.group}>
133+
<p aria-hidden='true' className={SUGGESTION_GROUP_LABEL_CLASS}>
134+
{group.group}
135+
</p>
136+
{group.items.map(({ item, index }) => {
137+
const Icon = item.icon
138+
return (
139+
<button
140+
key={`${item.kind}:${item.id}`}
141+
type='button'
142+
role='option'
143+
id={`mention-${index}`}
144+
aria-selected={index === activeIndex}
145+
data-index={index}
146+
className={cn(
147+
SUGGESTION_ITEM_CLASS,
148+
index === activeIndex && 'bg-[var(--surface-active)]'
149+
)}
150+
onMouseEnter={() => setActiveIndex(index)}
151+
onMouseDown={(event) => {
152+
event.preventDefault()
153+
command(item)
154+
}}
155+
>
156+
{Icon && <Icon />}
157+
<span>{item.label}</span>
158+
</button>
159+
)
160+
})}
161+
</div>
162+
))}
163+
</div>
164+
)
165+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { MentionItem } from './types'
2+
3+
/**
4+
* A tiny external store bridging React Query data (host component) into the `@` menu list, which is
5+
* rendered by TipTap's `ReactRenderer` as a detached root with no access to the app's React context
6+
* providers. The host pushes the latest items via {@link MentionStore.set}; the list subscribes with
7+
* `useSyncExternalStore` and re-renders when async data lands — so the menu populates live even if it
8+
* was opened before the data finished loading. One store instance lives per editor (in extension
9+
* storage).
10+
*/
11+
export interface MentionStore {
12+
getSnapshot: () => MentionItem[]
13+
subscribe: (listener: () => void) => () => void
14+
set: (items: MentionItem[]) => void
15+
}
16+
17+
export function createMentionStore(): MentionStore {
18+
let items: MentionItem[] = []
19+
const listeners = new Set<() => void>()
20+
return {
21+
getSnapshot: () => items,
22+
subscribe: (listener) => {
23+
listeners.add(listener)
24+
return () => listeners.delete(listener)
25+
},
26+
set: (next) => {
27+
if (next === items) return
28+
items = next
29+
for (const listener of listeners) listener()
30+
},
31+
}
32+
}

0 commit comments

Comments
 (0)