Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* @vitest-environment jsdom
*
* Regression guards for two bugs found while adding the `@` mention menu:
*
* 1. The `@` mention and `/` slash-command extensions each register a `@tiptap/suggestion` plugin.
* They must use distinct plugin keys, or constructing any editor with the full set throws
* "Adding different instances of a keyed plugin (suggestion$)".
*
* 2. A markdown file authored outside the editor (e.g. the former Monaco editor) is rarely in the
* editor's canonical serialization. On open, a deferred view-plugin transaction re-serializes the
* doc to canonical markdown and emits one update — which, compared against the raw saved bytes,
* falsely marks the file dirty ("unsaved changes"). The fix normalizes the dirty-check baseline to
* the canonical form; this asserts that normalized form equals what the live editor emits.
*/
import { Editor } from '@tiptap/core'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { createMarkdownEditorExtensions } from './extensions'
import {
applyFrontmatter,
postProcessSerializedMarkdown,
splitFrontmatter,
} from './markdown-fidelity'
import { parseMarkdownToDoc } from './markdown-parse'
import { normalizeMarkdownContent } from './normalize-content'

let editor: Editor | null = null
let host: HTMLElement | null = null

beforeAll(() => {
// jsdom lacks the layout APIs the Placeholder viewport plugin calls when a view mounts.
// @ts-expect-error jsdom stub
document.elementFromPoint = () => document.body
// @ts-expect-error jsdom stub
Range.prototype.getClientRects = () => [] as unknown as DOMRectList
Range.prototype.getBoundingClientRect = () => new DOMRect()
Element.prototype.getClientRects = () => [] as unknown as DOMRectList
})

afterEach(() => {
editor?.destroy()
editor = null
host?.remove()
host = null
})

describe('full extension set', () => {
it('mounts without a duplicate suggestion-plugin-key error (@ and / coexist)', () => {
expect(() => {
editor = new Editor({
extensions: createMarkdownEditorExtensions({ placeholder: 'x' }),
content: '',
})
}).not.toThrow()
})
})

describe('normalizeMarkdownContent — dirty-on-open baseline', () => {
it('normalizes non-canonical markdown to the editor canonical form', () => {
expect(normalizeMarkdownContent('* one\n* two\n')).toBe('- one\n- two\n')
})

it('is idempotent', () => {
for (const md of [
'* one\n* two\n',
'| a | b |\n| --- | --- |\n| 1 | 2 |\n',
'# H\n\nsome _emphasis_ here\n',
]) {
const once = normalizeMarkdownContent(md)
expect(normalizeMarkdownContent(once)).toBe(once)
}
})

it('leaves round-trip-unsafe content untouched (read-only files keep their raw bytes)', () => {
const unsafe = 'text with a footnote[^1]\n\n[^1]: the note\n'
expect(normalizeMarkdownContent(unsafe)).toBe(unsafe)
})
})

describe('baseline neutralizes the mount-time dirty signal', () => {
it('the editor mount serialization equals the normalized baseline (so isDirty stays false)', async () => {
const raw = '# H\n\n* bullet\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n> quote\n'
const { frontmatter, body } = splitFrontmatter(raw)
host = document.createElement('div')
document.body.appendChild(host)

let emitted: string | null = null
editor = new Editor({
element: host,
extensions: createMarkdownEditorExtensions({ placeholder: 'x' }),
content: parseMarkdownToDoc(body),
onUpdate: ({ editor }) => {
emitted = applyFrontmatter(frontmatter, postProcessSerializedMarkdown(editor.getMarkdown()))
},
})

await new Promise((resolve) => setTimeout(resolve, 30))

// The deferred mount transaction re-serializes to canonical markdown; the baseline must match it
// exactly, so `content === savedContent` and the file is never falsely dirty on open.
expect(emitted).not.toBeNull()
expect(emitted).toBe(normalizeMarkdownContent(raw))
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,16 @@ import { MarkdownImage, ResizableImage } from './image'
import { RichMarkdownKeymap } from './keymap'
import { MarkdownLinkInputRule } from './link-input-rule'
import { MarkdownPaste } from './markdown-paste'
import { MarkdownMention, Mention, MentionChip, SIM_LINK_SCHEME } from './mention'
import { SlashCommand } from './slash-command/slash-command'

/**
* The `@`-mention link scheme, registered on the Link mark — without it the schema strips the
* `sim:<kind>/<id>` href on parse/round-trip, dropping the mention. `optionalSlashes` allows the
* slash-less `sim:kind/id` form.
*/
const SIM_LINK_PROTOCOL = { scheme: SIM_LINK_SCHEME, optionalSlashes: true } as const

/**
* Inline code that can combine with bold/italic/strike (GFM permits `**`x`**`, `~~`x`~~`).
* The stock Code mark sets `excludes: '_'`, which blocks every other mark from coexisting and
Expand Down Expand Up @@ -78,14 +86,15 @@ export function createMarkdownContentExtensions({
})
return [
StarterKit.configure({
link: { openOnClick: false },
link: { openOnClick: false, protocols: [SIM_LINK_PROTOCOL] },
underline: false,
codeBlock: false,
code: false,
}),
InlineCode,
codeBlock,
(nodeViews ? ResizableImage : MarkdownImage).configure({ allowBase64: true }),
nodeViews ? MentionChip : MarkdownMention,
TaskList,
TaskItem.configure({ nested: true }),
PipeSafeTable.configure({ resizable: true }),
Expand All @@ -109,6 +118,7 @@ export function createMarkdownEditorExtensions({
...createMarkdownContentExtensions({ nodeViews: true }),
CodeBlockHighlight,
SlashCommand,
Mention,
RichMarkdownKeymap,
MarkdownPaste,
Placeholder.configure({ placeholder }),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { Mention, type MentionStorage } from './mention'
export { MarkdownMention, MentionChip } from './mention-node'
export { parseSimHref, SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link'
export type { MentionItem, MentionKind } from './types'
export { useEditorMentions } from './use-editor-mentions'
export { useMarkdownMentions } from './use-markdown-mentions'
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ComponentType } from 'react'
import { Database, File, Folder, Sparkles, Table, Workflow } from 'lucide-react'
import { getBlock } from '@/blocks/registry'
import type { MentionKind } from './types'

/** Icon component shape both the lucide kind-icons and the brand block icons satisfy. */
export type MentionIcon = ComponentType<{ className?: string }>

const KIND_ICONS: Record<Exclude<MentionKind, 'integration'>, MentionIcon> = {
file: File,
folder: Folder,
table: Table,
knowledge: Database,
workflow: Workflow,
skill: Sparkles,
}

/**
* Resolves the icon for a mention. Integrations use their brand icon from the block registry (keyed by
* blockType, which is the mention `id`); every other kind uses a lucide category icon. Shared by the
* menu rows and the inserted chip so both render the same icon.
*/
export function mentionIcon(kind: MentionKind, id: string): MentionIcon | undefined {
if (kind === 'integration') return getBlock(id)?.icon as MentionIcon | undefined
return KIND_ICONS[kind]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import { cn } from '@/lib/core/utils/cn'
import {
SUGGESTION_GROUP_LABEL_CLASS,
SUGGESTION_ITEM_CLASS,
SUGGESTION_SCROLL_CLASS,
SUGGESTION_SURFACE_CLASS,
} from '../menus/suggestion-menu-chrome'
import type { MentionStore } from './mention-store'
import type { MentionItem } from './types'

export interface MentionListHandle {
onKeyDown: (props: { event: KeyboardEvent }) => boolean
}

interface MentionListProps {
/** The text typed after `@`, used to filter. */
query: string
/** Inserts the chosen mention (wired to the suggestion `command`). */
command: (item: MentionItem) => void
/** Live data source the host keeps populated. */
store: MentionStore
}

/** Per-group cap so a large workspace can't flood the menu; filtering still searches the full set. */
const MAX_PER_GROUP = 8

/** Category heading order in the menu. */
const GROUP_ORDER = [
'Files',
'Folders',
'Tables',
'Knowledge bases',
'Workflows',
'Skills',
'Integrations',
] as const

/**
* The `@` mention popup. Sibling of {@link SlashCommandList} with identical chrome and arrow/enter
* navigation, but its items come reactively from the editor's {@link MentionStore} (via
* `useSyncExternalStore`) rather than props — so the list fills in as async workspace data lands.
*/
export const MentionList = forwardRef<MentionListHandle, MentionListProps>(function MentionList(
{ query, command, store },
ref
) {
const rawItems = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
const [activeIndex, setActiveIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)

/** Filtered, group-capped, flattened in category order; `index` is the flat position for nav. */
const { flat, groups } = useMemo(() => {
const q = query.trim().toLowerCase()
// One pass over the full set: filter by label and bucket by group (capped), then read the
// buckets in category order — avoids a separate filter pass per group.
const byGroup = new Map<string, MentionItem[]>()
for (const item of rawItems) {
if (q && !item.label.toLowerCase().includes(q)) continue
const bucket = byGroup.get(item.group)
if (!bucket) byGroup.set(item.group, [item])
else if (bucket.length < MAX_PER_GROUP) bucket.push(item)
}

const ordered: { group: string; items: { item: MentionItem; index: number }[] }[] = []
const flat: MentionItem[] = []
for (const group of GROUP_ORDER) {
const inGroup = byGroup.get(group)
if (!inGroup) continue
ordered.push({ group, items: inGroup.map((item) => ({ item, index: flat.push(item) - 1 })) })
}
return { flat, groups: ordered }
}, [rawItems, query])

useEffect(() => {
setActiveIndex(0)
}, [flat])

useEffect(() => {
containerRef.current
?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
?.scrollIntoView({ block: 'nearest' })
}, [activeIndex])

useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (flat.length === 0) return false
if (event.key === 'ArrowUp') {
setActiveIndex((i) => (i + flat.length - 1) % flat.length)
return true
}
if (event.key === 'ArrowDown') {
setActiveIndex((i) => (i + 1) % flat.length)
return true
}
if (event.key === 'Enter') {
const item = flat[activeIndex]
if (!item) return false
command(item)
return true
}
return false
},
}))

if (flat.length === 0) {
return (
<div className={SUGGESTION_SURFACE_CLASS}>
<p className='px-2 py-1.5 text-[var(--text-tertiary)] text-caption'>
{rawItems.length === 0 ? 'Loading…' : 'No results'}
</p>
</div>
)
}

return (
<div
ref={containerRef}
role='listbox'
aria-label='Mentions'
className={cn(SUGGESTION_SURFACE_CLASS, SUGGESTION_SCROLL_CLASS)}
>
{groups.map((group) => (
<div key={group.group} role='group' aria-label={group.group}>
<p aria-hidden='true' className={SUGGESTION_GROUP_LABEL_CLASS}>
{group.group}
</p>
{group.items.map(({ item, index }) => {
const Icon = item.icon
return (
<button
key={`${item.kind}:${item.id}`}
type='button'
role='option'
id={`mention-${index}`}
aria-selected={index === activeIndex}
data-index={index}
className={cn(
SUGGESTION_ITEM_CLASS,
index === activeIndex && 'bg-[var(--surface-active)]'
)}
onMouseEnter={() => setActiveIndex(index)}
onMouseDown={(event) => {
event.preventDefault()
command(item)
}}
>
{Icon && <Icon />}
<span>{item.label}</span>
</button>
)
})}
</div>
))}
</div>
)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @vitest-environment jsdom
*
* The `@`-mention is stored as a portable `[label](sim:<kind>/<id>)` markdown link but parses into a
* dedicated `mention` node (rendered live as a chip). These guard that the parse → node → serialize
* cycle is lossless, so the chat-portable wire format and the chip rendering stay in sync.
*/
import type { JSONContent } from '@tiptap/core'
import { describe, expect, it } from 'vitest'
import { parseMarkdownToDoc, serializeMarkdownBody } from '../markdown-parse'

function findMention(node: JSONContent): JSONContent | null {
if (node.type === 'mention') return node
for (const child of node.content ?? []) {
const found = findMention(child)
if (found) return found
}
return null
}

describe('mention node round-trip', () => {
it('parses a sim: link into a mention node with kind/id/label', () => {
const doc = parseMarkdownToDoc('See [Airweave](sim:integration/airweave) here')
const mention = findMention(doc)
expect(mention).not.toBeNull()
expect(mention?.attrs).toEqual({ kind: 'integration', id: 'airweave', label: 'Airweave' })
})

it('serializes a mention node back to the portable sim: link', () => {
for (const input of [
'See [Airweave](sim:integration/airweave) here',
'[my-skill](sim:skill/abc-123)',
'a [Spec.md](sim:file/xyz_789) b',
]) {
expect(serializeMarkdownBody(input).trim()).toBe(input)
}
})

it('leaves a normal http link as a link, not a mention', () => {
const doc = parseMarkdownToDoc('[Sim](https://sim.ai)')
expect(findMention(doc)).toBeNull()
})
})
Loading
Loading