From f296e76027cb40a719c975c8fa9a6ab6d3844a37 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 14:38:50 -0700 Subject: [PATCH 01/10] feat(rich-editor): rich markdown field + @ mentions for skill & deploy modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../dirty-on-open.test.ts | 104 +++++++++++ .../rich-markdown-editor/extensions.ts | 11 +- .../rich-markdown-editor/mention/index.ts | 5 + .../mention/mention-list.tsx | 165 ++++++++++++++++++ .../mention/mention-store.ts | 32 ++++ .../rich-markdown-editor/mention/mention.ts | 89 ++++++++++ .../mention/sim-link.test.ts | 34 ++++ .../rich-markdown-editor/mention/sim-link.ts | 46 +++++ .../rich-markdown-editor/mention/types.ts | 29 +++ .../mention/use-editor-mentions.ts | 27 +++ .../mention/use-markdown-mentions.ts | 99 +++++++++++ .../menus/suggestion-menu-chrome.ts | 21 +++ .../menus/suggestion-popup.ts | 99 +++++++++++ .../rich-markdown-editor/normalize-content.ts | 23 +++ .../rich-markdown-editor.css | 14 ++ .../rich-markdown-editor.tsx | 13 ++ .../rich-markdown-field.tsx | 164 +++++++++++++++++ .../rich-markdown-editor/round-trip.test.ts | 7 + .../slash-command/slash-command-list.tsx | 29 ++- .../slash-command/slash-command.ts | 82 ++------- .../file-viewer/use-editable-file-content.ts | 30 +++- .../components/skill-import/skill-import.tsx | 117 ++++--------- .../components/skill-modal/skill-modal.tsx | 83 ++++++--- .../components/version-description-modal.tsx | 48 +++-- .../general/components/versions.tsx | 26 ++- .../general/format-version-label.ts | 8 + .../components/general/general.tsx | 23 ++- 27 files changed, 1205 insertions(+), 223 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-store.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/types.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts new file mode 100644 index 00000000000..7c45efe601f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts @@ -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)) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index 2610daac7b4..defb1332247 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -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 { Mention, 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:/` 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 @@ -78,7 +86,7 @@ export function createMarkdownContentExtensions({ }) return [ StarterKit.configure({ - link: { openOnClick: false }, + link: { openOnClick: false, protocols: [SIM_LINK_PROTOCOL] }, underline: false, codeBlock: false, code: false, @@ -109,6 +117,7 @@ export function createMarkdownEditorExtensions({ ...createMarkdownContentExtensions({ nodeViews: true }), CodeBlockHighlight, SlashCommand, + Mention, RichMarkdownKeymap, MarkdownPaste, Placeholder.configure({ placeholder }), diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts new file mode 100644 index 00000000000..361f282333d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts @@ -0,0 +1,5 @@ +export { Mention, type MentionStorage } from './mention' +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' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx new file mode 100644 index 00000000000..2e892ff4565 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx @@ -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(function MentionList( + { query, command, store }, + ref +) { + const rawItems = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) + const [activeIndex, setActiveIndex] = useState(0) + const containerRef = useRef(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() + 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(`[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 ( +
+

+ {rawItems.length === 0 ? 'Loading…' : 'No results'} +

+
+ ) + } + + return ( +
+ {groups.map((group) => ( +
+ + {group.items.map(({ item, index }) => { + const Icon = item.icon + return ( + + ) + })} +
+ ))} +
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-store.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-store.ts new file mode 100644 index 00000000000..f3ac59d24b3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-store.ts @@ -0,0 +1,32 @@ +import type { MentionItem } from './types' + +/** + * A tiny external store bridging React Query data (host component) into the `@` menu list, which is + * rendered by TipTap's `ReactRenderer` as a detached root with no access to the app's React context + * providers. The host pushes the latest items via {@link MentionStore.set}; the list subscribes with + * `useSyncExternalStore` and re-renders when async data lands — so the menu populates live even if it + * was opened before the data finished loading. One store instance lives per editor (in extension + * storage). + */ +export interface MentionStore { + getSnapshot: () => MentionItem[] + subscribe: (listener: () => void) => () => void + set: (items: MentionItem[]) => void +} + +export function createMentionStore(): MentionStore { + let items: MentionItem[] = [] + const listeners = new Set<() => void>() + return { + getSnapshot: () => items, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + set: (next) => { + if (next === items) return + items = next + for (const listener of listeners) listener() + }, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts new file mode 100644 index 00000000000..498a3212197 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts @@ -0,0 +1,89 @@ +import { Extension } from '@tiptap/core' +import { PluginKey } from '@tiptap/pm/state' +import Suggestion from '@tiptap/suggestion' +import { createSuggestionPopupRenderer } from '../menus/suggestion-popup' +import { MentionList } from './mention-list' +import { createMentionStore, type MentionStore } from './mention-store' +import { toSimHref } from './sim-link' +import type { MentionItem } from './types' + +/** Distinct from the `/` slash command's default `suggestion` key — two plugins can't share one key. */ +const MENTION_PLUGIN_KEY = new PluginKey('mention') + +/** + * Per-editor storage for the `@` mention extension. The host component populates {@link store} with + * the current workspace mention data and may set {@link onOpen} to lazily start fetching that data the + * first time the menu is triggered. {@link enabled} gates the menu off entirely (e.g. a field with no + * workspace scope) so `@` stays literal text. + */ +export interface MentionStorage { + store: MentionStore + onOpen: (() => void) | null + enabled: boolean +} + +declare module '@tiptap/core' { + interface Storage { + mention: MentionStorage + } +} + +/** + * Adds the `@` mention menu to the editor. Typing `@` at the start of a block — or after whitespace — + * opens {@link MentionList}; selecting an entity inserts it as a portable `sim:/` markdown + * link (same wire format as the chat composer's `chip-clipboard-codec`), so it round-trips natively + * through the editor's link + markdown machinery. The menu's data is supplied by the host via the + * extension's `mention` storage. + */ +export const Mention = Extension.create, MentionStorage>({ + name: 'mention', + + addStorage() { + return { store: createMentionStore(), onOpen: null, enabled: true } + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + pluginKey: MENTION_PLUGIN_KEY, + char: '@', + allowSpaces: false, + startOfLine: false, + allow: ({ editor, range }) => { + if (!editor.storage.mention.enabled) return false + if (editor.isActive('codeBlock') || editor.isActive('link') || editor.isActive('code')) { + return false + } + const $from = editor.state.doc.resolve(range.from) + if ($from.parentOffset === 0) return true + // Only after whitespace, so `@` inside an email/handle (`name@host`) never triggers. + return /\s/.test($from.parent.textBetween($from.parentOffset - 1, $from.parentOffset)) + }, + // Items are sourced reactively from the store inside MentionList; this only gates the plugin. + items: () => [], + command: ({ editor, range, props }) => { + const href = toSimHref(props.kind, props.id) + editor + .chain() + .focus() + .deleteRange(range) + .insertContent([ + { type: 'text', text: props.label, marks: [{ type: 'link', attrs: { href } }] }, + { type: 'text', text: ' ' }, + ]) + .run() + }, + render: createSuggestionPopupRenderer({ + component: MentionList, + mapProps: (props) => ({ + query: props.query, + command: props.command, + store: props.editor.storage.mention.store, + }), + onOpen: (props) => props.editor.storage.mention.onOpen?.(), + }), + }), + ] + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts new file mode 100644 index 00000000000..e33a4359f28 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { parseSimHref, simLinkPath } from './sim-link' + +describe('parseSimHref', () => { + it('parses a sim mention href', () => { + expect(parseSimHref('sim:file/abc-123')).toEqual({ kind: 'file', id: 'abc-123' }) + expect(parseSimHref('sim:knowledge/kb_1')).toEqual({ kind: 'knowledge', id: 'kb_1' }) + }) + + it('returns null for non-sim hrefs', () => { + expect(parseSimHref('https://sim.ai')).toBeNull() + expect(parseSimHref('sim:file')).toBeNull() + expect(parseSimHref('mailto:x@y.com')).toBeNull() + }) +}) + +describe('simLinkPath', () => { + const ws = 'ws1' + + // Each destination must match a real route — skills/folders deep-link via query params (no [id] route). + it('resolves every kind to its real in-app route', () => { + expect(simLinkPath(ws, 'file', 'f1')).toBe('/workspace/ws1/files/f1/view') + expect(simLinkPath(ws, 'folder', 'd1')).toBe('/workspace/ws1/files?folderId=d1') + expect(simLinkPath(ws, 'table', 't1')).toBe('/workspace/ws1/tables/t1') + expect(simLinkPath(ws, 'knowledge', 'k1')).toBe('/workspace/ws1/knowledge/k1') + expect(simLinkPath(ws, 'workflow', 'w1')).toBe('/workspace/ws1/w/w1') + expect(simLinkPath(ws, 'skill', 's1')).toBe('/workspace/ws1/skills?skillId=s1') + expect(simLinkPath(ws, 'integration', 'slack')).toBe('/workspace/ws1/integrations/slack') + }) + + it('returns null for an unknown kind', () => { + expect(simLinkPath(ws, 'mystery', 'x')).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts new file mode 100644 index 00000000000..cfb526f44bd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts @@ -0,0 +1,46 @@ +/** + * The link scheme for `@`-mention links — `[label](sim:/)`. Matches the chat composer's + * portable chip format (`chip-clipboard-codec.ts`), so a mention authored here is parseable there. + */ +export const SIM_LINK_SCHEME = 'sim' + +/** A bare `sim:/` mention href (the link target inserted by the `@` menu). */ +const SIM_HREF_PATTERN = /^sim:([a-z_]+)\/(.+)$/ + +/** Builds the link target for a mention of `kind`/`id`. */ +export function toSimHref(kind: string, id: string): string { + return `${SIM_LINK_SCHEME}:${kind}/${id}` +} + +/** Parses a `sim:/` href into its parts, or `null` if it isn't a sim mention link. */ +export function parseSimHref(href: string): { kind: string; id: string } | null { + const match = href.match(SIM_HREF_PATTERN) + return match ? { kind: match[1], id: match[2] } : null +} + +/** + * Resolves the in-app route for a clicked `sim:` mention link, or `null` for an unknown kind. Each + * destination matches the entity's real route: files open the file detail view, folders/skills deep-link + * the file browser / skills modal via their query params, the rest hit their `[id]` route. + */ +export function simLinkPath(workspaceId: string, kind: string, id: string): string | null { + const base = `/workspace/${workspaceId}` + switch (kind) { + case 'file': + return `${base}/files/${id}/view` + case 'folder': + return `${base}/files?folderId=${id}` + case 'table': + return `${base}/tables/${id}` + case 'knowledge': + return `${base}/knowledge/${id}` + case 'workflow': + return `${base}/w/${id}` + case 'skill': + return `${base}/skills?skillId=${id}` + case 'integration': + return `${base}/integrations/${id}` + default: + return null + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/types.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/types.ts new file mode 100644 index 00000000000..dd87663dcc7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/types.ts @@ -0,0 +1,29 @@ +import type { ComponentType } from 'react' + +/** + * The workspace entity kinds that can be `@`-mentioned in a markdown editor. A deliberate subset of + * the chat's portable kinds (`chip-clipboard-codec.ts`) — the workspace-scoped ones that exist + * without a workflow runtime context. The string values match that codec's `sim:/` scheme, + * so a mention link inserted here is parseable by `parseChipLinks()`. + */ +export type MentionKind = + | 'file' + | 'folder' + | 'table' + | 'knowledge' + | 'workflow' + | 'skill' + | 'integration' + +/** A single selectable entry in the `@` menu. */ +export interface MentionItem { + kind: MentionKind + /** Entity id used as `sim:/` in the inserted link. */ + id: string + /** Display + link text. */ + label: string + /** Category heading the item is shown under. */ + group: string + /** Optional per-item icon (Lucide category icon or a brand block icon). */ + icon?: ComponentType<{ className?: string }> +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts new file mode 100644 index 00000000000..a40776f26ad --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react' +import type { Editor } from '@tiptap/react' +import { useMarkdownMentions } from './use-markdown-mentions' + +/** + * Wires an editor's `@` mention menu to its workspace data: gates the menu on a workspace scope, + * lazily fetches the data on the first open, and feeds it into the menu's reactive store. Shared by + * every editor surface that mounts the mention extension (the file editor and the modal field). + */ +export function useEditorMentions(editor: Editor | null, workspaceId: string | undefined): void { + const [active, setActive] = useState(false) + const items = useMarkdownMentions(workspaceId, { enabled: active }) + + useEffect(() => { + if (!editor) return + const hasWorkspace = Boolean(workspaceId) + editor.storage.mention.enabled = hasWorkspace + editor.storage.mention.onOpen = hasWorkspace ? () => setActive(true) : null + return () => { + editor.storage.mention.onOpen = null + } + }, [editor, workspaceId]) + + useEffect(() => { + editor?.storage.mention.store.set(items) + }, [editor, items]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts new file mode 100644 index 00000000000..d75ed04456c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts @@ -0,0 +1,99 @@ +import { useMemo } from 'react' +import { Database, File, Folder, Sparkles, Table, Workflow } from 'lucide-react' +import { listIntegrations } from '@/blocks/integration-matcher' +import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' +import { useSkills } from '@/hooks/queries/skills' +import { useTablesList } from '@/hooks/queries/tables' +import { useWorkflows } from '@/hooks/queries/workflows' +import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders' +import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import type { MentionItem } from './types' + +/** + * Aggregates the workspace-scoped entities the `@` menu can reference, composing the canonical + * per-resource React Query hooks (never the chat-coupled `useAvailableResources` aggregator). All + * queries stay disabled until `enabled` flips true — the host activates it on the first `@` trigger — + * so a markdown field that never opens the menu fetches nothing. + */ +export function useMarkdownMentions( + workspaceId: string | undefined, + options: { enabled: boolean } +): MentionItem[] { + const active = options.enabled && Boolean(workspaceId) + // Pass through only when active; each hook self-gates on a falsy workspaceId. + const ws = active ? workspaceId : undefined + const wsStr = ws ?? '' + + const files = useWorkspaceFiles(wsStr, 'active', { enabled: active }) + const folders = useWorkspaceFileFolders(wsStr, 'active') + const tables = useTablesList(ws, 'active') + const knowledgeBases = useKnowledgeBasesQuery(ws, { enabled: active }) + const workflows = useWorkflows(ws) + const skills = useSkills(wsStr) + + // The integration registry is static — materialize it once rather than on every resource refetch. + const integrationItems = useMemo(() => { + if (!active) return [] + return listIntegrations().map((integration) => ({ + kind: 'integration', + id: integration.blockType, + label: integration.name, + group: 'Integrations', + icon: integration.icon, + })) + }, [active]) + + return useMemo(() => { + if (!active) return [] + const items: MentionItem[] = [] + + for (const file of files.data ?? []) + items.push({ kind: 'file', id: file.id, label: file.name, group: 'Files', icon: File }) + for (const folder of folders.data ?? []) + items.push({ + kind: 'folder', + id: folder.id, + label: folder.name, + group: 'Folders', + icon: Folder, + }) + for (const table of tables.data ?? []) + items.push({ kind: 'table', id: table.id, label: table.name, group: 'Tables', icon: Table }) + for (const kb of knowledgeBases.data ?? []) + items.push({ + kind: 'knowledge', + id: kb.id, + label: kb.name, + group: 'Knowledge bases', + icon: Database, + }) + for (const workflow of workflows.data ?? []) + items.push({ + kind: 'workflow', + id: workflow.id, + label: workflow.name, + group: 'Workflows', + icon: Workflow, + }) + for (const skill of skills.data ?? []) + items.push({ + kind: 'skill', + id: skill.id, + label: skill.name, + group: 'Skills', + icon: Sparkles, + }) + items.push(...integrationItems) + + return items + }, [ + active, + files.data, + folders.data, + tables.data, + knowledgeBases.data, + workflows.data, + skills.data, + integrationItems, + ]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts new file mode 100644 index 00000000000..76ebf4b2acd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts @@ -0,0 +1,21 @@ +/** + * Shared chrome for the editor's keyboard-driven suggestion popups — the `/` slash-command menu and + * the `@` mention menu. Single source of truth so the two read identically; never re-derive these + * class strings per consumer. + */ + +/** The floating panel: bordered card with the enter animation. */ +export const SUGGESTION_SURFACE_CLASS = + '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' + +/** A scrollable list body (hidden scrollbar), added alongside {@link SUGGESTION_SURFACE_CLASS}. */ +export const SUGGESTION_SCROLL_CLASS = + 'max-h-[330px] scroll-py-1.5 overflow-y-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' + +/** A selectable row: icon + label, 14px icon in `--text-icon`, truncating label. */ +export const SUGGESTION_ITEM_CLASS = + 'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]' + +/** A group heading above a run of rows. */ +export const SUGGESTION_GROUP_LABEL_CLASS = + 'px-2 pt-1.5 pb-1 font-medium text-[var(--text-muted)] text-micro uppercase tracking-wide' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts new file mode 100644 index 00000000000..66b27bf61c1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts @@ -0,0 +1,99 @@ +import type { ForwardRefExoticComponent, PropsWithoutRef, RefAttributes } from 'react' +import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom' +import { ReactRenderer } from '@tiptap/react' +import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion' + +/** The imperative handle every suggestion list exposes so the popup can forward arrow/enter keys to it. */ +export interface SuggestionListHandle { + onKeyDown: (props: { event: KeyboardEvent }) => boolean +} + +type AnySuggestionProps = SuggestionProps + +function positionPopup(element: HTMLElement, getRect: AnySuggestionProps['clientRect']) { + const rect = getRect?.() + if (!rect) return + const virtualEl = { getBoundingClientRect: () => rect } + computePosition(virtualEl, element, { + placement: 'bottom-start', + strategy: 'fixed', + middleware: [offset(6), flip({ padding: 8 }), shift({ padding: 8 })], + }).then(({ x, y }) => { + if (!element.isConnected) return + element.style.left = `${x}px` + element.style.top = `${y}px` + }) +} + +interface SuggestionPopupConfig { + /** The React list component, mounted via `ReactRenderer` into a detached, floating body element. */ + component: ForwardRefExoticComponent & RefAttributes> + /** Maps the live suggestion props to the list component's props. */ + mapProps: (props: AnySuggestionProps) => P + /** Called once when the popup opens, before mount — e.g. to lazily start data fetching. */ + onOpen?: (props: AnySuggestionProps) => void +} + +/** + * Builds the `render` lifecycle for a `@tiptap/suggestion` popup: mounts a React list into a fixed, + * floating-ui-positioned body element, repositions on update/scroll, forwards keys to the list's + * imperative handle, and tears everything down on exit / Escape / editor-destroy. Shared by the `/` + * slash command and the `@` mention menu so the popup mechanics live in exactly one place. + */ +export function createSuggestionPopupRenderer( + config: SuggestionPopupConfig +): NonNullable { + return () => { + let component: ReactRenderer | null = null + let popup: HTMLElement | null = null + let boundEditor: AnySuggestionProps['editor'] | null = null + let stopAutoUpdate: (() => void) | null = null + + const teardown = () => { + stopAutoUpdate?.() + stopAutoUpdate = null + boundEditor?.off('destroy', teardown) + boundEditor = null + popup?.remove() + component?.destroy() + popup = null + component = null + } + + return { + onStart: (props) => { + teardown() + config.onOpen?.(props) + component = new ReactRenderer(config.component, { + // ReactRenderer types its props option loosely; the component still enforces P. + props: config.mapProps(props) as Record, + editor: props.editor, + }) + popup = document.createElement('div') + popup.className = 'fixed top-0 left-0 z-[var(--z-popover)]' + popup.appendChild(component.element) + document.body.appendChild(popup) + boundEditor = props.editor + boundEditor.on('destroy', teardown) + const reference = { getBoundingClientRect: () => props.clientRect?.() ?? new DOMRect() } + const surface = popup + stopAutoUpdate = autoUpdate(reference, surface, () => + positionPopup(surface, props.clientRect) + ) + }, + onUpdate: (props) => { + component?.updateProps(config.mapProps(props) as Record) + if (popup) positionPopup(popup, props.clientRect) + }, + onKeyDown: (props) => { + if (props.event.isComposing) return false + if (props.event.key === 'Escape') { + teardown() + return true + } + return component?.ref?.onKeyDown(props) ?? false + }, + onExit: teardown, + } + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts new file mode 100644 index 00000000000..f50fdd98647 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content.ts @@ -0,0 +1,23 @@ +import { + applyFrontmatter, + postProcessSerializedMarkdown, + splitFrontmatter, +} from './markdown-fidelity' +import { serializeMarkdownBody } from './markdown-parse' +import { isRoundTripSafe } from './round-trip-safety' + +/** + * The canonical form the rich editor serializes a document to (`*`→`-` bullets, padded table cells, + * `_em_`→`*em*`, …). A markdown file authored elsewhere (e.g. the former Monaco editor) is rarely in + * this form, so the editor's first mount-time re-serialization would otherwise read as an unsaved edit + * and falsely mark the file dirty. Normalizing the dirty-check baseline to this exact form on open + * neutralizes that — verified to match the live editor's own serialization byte-for-byte. + * + * Round-trip-UNSAFE content (raw HTML, footnotes, >128KB) is returned untouched: those files open + * read-only and must display their original bytes, never a lossy re-serialization. + */ +export function normalizeMarkdownContent(raw: string): string { + if (!isRoundTripSafe(raw)) return raw + const { frontmatter, body } = splitFrontmatter(raw) + return applyFrontmatter(frontmatter, postProcessSerializedMarkdown(serializeMarkdownBody(body))) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index 6abc89a6010..b580b6c4628 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -322,3 +322,17 @@ height: 0; pointer-events: none; } + +/* + * Field variant (modal embed): match the surrounding chip fields' typography exactly — + * body at the chip `text-sm` (14px) scale and the placeholder at `--text-muted` (not the + * lighter document `--text-subtle`), so the editor reads as one of the form's fields. + */ +.rich-markdown-field-prose { + font-size: 14px; + line-height: 22px; +} + +.rich-markdown-field-prose p.is-editor-empty:first-child::before { + color: var(--text-muted); +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index d4d10637113..3d1b18619a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -21,8 +21,10 @@ import { splitFrontmatter, } from './markdown-fidelity' import { parseMarkdownToDoc } from './markdown-parse' +import { parseSimHref, simLinkPath, useEditorMentions } from './mention' import { EditorBubbleMenu } from './menus/bubble-menu' import { LinkHoverCard } from './menus/link-hover-card' +import { normalizeMarkdownContent } from './normalize-content' import { isRoundTripSafe } from './round-trip-safety' import '@/components/emcn/components/code/code.css' import './rich-markdown-editor.css' @@ -86,6 +88,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ onDirtyChange, onSaveStatusChange, saveRef, + normalizeBaseline: normalizeMarkdownContent, }) if (isContentLoading) return @@ -258,6 +261,14 @@ export function LoadedRichMarkdownEditor({ }) return true } + // A `@`-mention link (`sim:/`) navigates to the referenced resource in-app. + if (href.startsWith('sim:')) { + const parsed = parseSimHref(href) + const path = parsed && simLinkPath(workspaceId, parsed.kind, parsed.id) + if (!path) return false + routerRef.current.push(path) + return true + } const normalized = normalizeLinkHref(href) if (!normalized) return false // A same-origin in-app path navigates within the SPA (same tab); external URLs open a new tab. @@ -311,6 +322,8 @@ export function LoadedRichMarkdownEditor({ } }, [editor]) + useEditorMentions(editor, workspaceId) + const wasStreamingRef = useRef(streamingAtMountRef.current) const pendingStreamBodyRef = useRef(null) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx new file mode 100644 index 00000000000..92af6e2a49d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -0,0 +1,164 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import type { JSONContent } from '@tiptap/core' +import { EditorContent, useEditor } from '@tiptap/react' +import { chipFieldSurfaceClass } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import { createMarkdownEditorExtensions } from './extensions' +import { + applyFrontmatter, + postProcessSerializedMarkdown, + splitFrontmatter, +} from './markdown-fidelity' +import { parseMarkdownToDoc } from './markdown-parse' +import { useEditorMentions } from './mention' +import { EditorBubbleMenu } from './menus/bubble-menu' +import { LinkHoverCard } from './menus/link-hover-card' +import '@/components/emcn/components/code/code.css' +import './rich-markdown-editor.css' + +interface RichMarkdownFieldProps { + /** Current markdown value. Seeds the editor once on mount; external changes only apply while {@link isStreaming}. */ + value: string + /** Fires with the serialized markdown on every local edit. */ + onChange: (markdown: string) => void + placeholder?: string + /** Renders the editor read-only (e.g. while saving). */ + disabled?: boolean + /** True while `value` is being pushed in externally (AI generation) — the editor turns read-only and mirrors each update. */ + isStreaming?: boolean + autoFocus?: boolean + /** Min height of the scroll box in px. */ + minHeight?: number + /** Max height of the scroll box in px before it scrolls. */ + maxHeight?: number + /** Swaps the border to the error token (the message itself is rendered by the surrounding field). */ + error?: boolean + /** Enables the `@` mention menu scoped to this workspace. Omit to disable mentions. */ + workspaceId?: string + /** + * Intercepts a plain-text paste before the editor handles it. Return `true` to consume the paste + * (e.g. a full document the host destructures elsewhere); `false` to fall through to normal + * markdown paste. + */ + onPasteText?: (text: string) => boolean +} + +/** + * A controlled, string-valued WYSIWYG markdown editor for modal fields — the file-less sibling of + * {@link RichMarkdownEditor}. It reuses the same TipTap extensions, parser, and menus but owns no file + * loading, autosave, or image upload. Drop it inside a `ChipModalField type='custom'`. + */ +export function RichMarkdownField({ + value, + onChange, + placeholder = "Write something, or press '/' for commands…", + disabled = false, + isStreaming = false, + autoFocus = false, + minHeight = 140, + maxHeight = 360, + error = false, + workspaceId, + onPasteText, +}: RichMarkdownFieldProps) { + const containerRef = useRef(null) + + // Frontmatter is held out-of-band and re-attached on serialize, exactly like the file editor. + // Split once at mount — the refs and the seed doc all derive from the initial value. + const [initialSplit] = useState(() => splitFrontmatter(value)) + const frontmatterRef = useRef(initialSplit.frontmatter) + // The body last reflected into the editor — updated on local edits and on each streamed sync. + const lastSyncedBodyRef = useRef(initialSplit.body) + const onChangeRef = useRef(onChange) + onChangeRef.current = onChange + const onPasteTextRef = useRef(onPasteText) + onPasteTextRef.current = onPasteText + + // TipTap extensions are stateful — build them once per mount so each field gets its own placeholder. + const [extensions] = useState(() => createMarkdownEditorExtensions({ placeholder })) + const [initialContent] = useState(() => parseMarkdownToDoc(initialSplit.body)) + + const editor = useEditor({ + extensions, + editable: !disabled && !isStreaming, + autofocus: autoFocus ? 'end' : false, + immediatelyRender: false, + shouldRerenderOnTransaction: false, + content: initialContent, + editorProps: { + attributes: { class: 'rich-markdown-prose rich-markdown-field-prose' }, + handlePaste: (_view, event) => { + const handler = onPasteTextRef.current + if (!handler) return false + const text = event.clipboardData?.getData('text/plain') + if (!text) return false + return handler(text) + }, + }, + onUpdate: ({ editor }) => { + const md = postProcessSerializedMarkdown(editor.getMarkdown()) + lastSyncedBodyRef.current = md + onChangeRef.current(applyFrontmatter(frontmatterRef.current, md)) + }, + }) + + // Mirror an externally-driven value (AI generation) into the editor, then settle to editable. + const wasStreamingRef = useRef(isStreaming) + useEffect(() => { + if (!editor) return + const { frontmatter, body } = splitFrontmatter(value) + frontmatterRef.current = frontmatter + + if (isStreaming) { + wasStreamingRef.current = true + if (editor.isEditable) editor.setEditable(false) + if (body === lastSyncedBodyRef.current) return + lastSyncedBodyRef.current = body + const el = containerRef.current + const pinnedToBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 60 : false + editor.commands.setContent(parseMarkdownToDoc(body), { + contentType: 'json', + emitUpdate: false, + }) + if (el && pinnedToBottom) el.scrollTop = el.scrollHeight + return + } + + // Settle: re-seed the freshly-generated body once, then restore editability. + if (wasStreamingRef.current) { + wasStreamingRef.current = false + if (body !== lastSyncedBodyRef.current) { + lastSyncedBodyRef.current = body + editor.commands.setContent(parseMarkdownToDoc(body), { + contentType: 'json', + emitUpdate: false, + }) + } + } + if (editor.isEditable !== !disabled) editor.setEditable(!disabled) + }, [editor, value, isStreaming, disabled]) + + useEditorMentions(editor, workspaceId) + + return ( +
+ {editor && } + {editor && } + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts index 5244548bbfe..cc25c8dfa7b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts @@ -162,6 +162,13 @@ describe('editor markdown round-trip', () => { }) } + // The `@`-mention link scheme must survive the schema, or the mention is silently stripped to + // plain text (which idempotency above can't detect). See the `sim` protocol in extensions.ts. + it('preserves a @-mention sim: link', () => { + const input = 'see [my-skill](sim:skill/abc123) and [Spec](sim:file/xyz-789)' + expect(roundTrip(input)).toBe(input) + }) + it('preserves frontmatter through a full round-trip', () => { const input = '---\ntitle: Hello\ntags: [a, b]\n---\n\n# Body\n\ntext' const out = roundTrip(input) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx index d9f10a7e8db..ad80e4ff74a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx @@ -1,5 +1,11 @@ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } 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 { SlashCommandItem } from './commands' export interface SlashCommandListHandle { @@ -11,12 +17,6 @@ interface SlashCommandListProps { command: (item: SlashCommandItem) => void } -const SURFACE_CLASS = - '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' - -const ITEM_CLASS = - 'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]' - /** * The `/` command popup. Mirrors the Chat composer's skills menu — same item chrome, * grouped headings, and arrow/enter keyboard navigation — so the two feel identical. @@ -70,7 +70,7 @@ export const SlashCommandList = forwardRef +

No results

) @@ -81,17 +81,11 @@ export const SlashCommandList = forwardRef {groups.map((group) => (
- {group.items.map(({ item, index }) => { @@ -104,7 +98,10 @@ export const SlashCommandList = forwardRef setActiveIndex(index)} onMouseDown={(event) => { event.preventDefault() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts index 1ceff0c9eed..737317a3f08 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts @@ -1,15 +1,13 @@ -import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom' -import type { Editor } from '@tiptap/core' import { Extension } from '@tiptap/core' -import { ReactRenderer } from '@tiptap/react' -import Suggestion, { type SuggestionOptions, type SuggestionProps } from '@tiptap/suggestion' +import Suggestion from '@tiptap/suggestion' +import { createSuggestionPopupRenderer } from '../menus/suggestion-popup' import { filterSlashCommands, type SlashCommandContext, type SlashCommandItem, type SlashCommandStorage, } from './commands' -import { SlashCommandList, type SlashCommandListHandle } from './slash-command-list' +import { SlashCommandList } from './slash-command-list' declare module '@tiptap/core' { interface Storage { @@ -17,72 +15,6 @@ declare module '@tiptap/core' { } } -type SlashSuggestionProps = SuggestionProps - -function positionPopup(element: HTMLElement, getRect: SlashSuggestionProps['clientRect']) { - const rect = getRect?.() - if (!rect) return - const virtualEl = { getBoundingClientRect: () => rect } - computePosition(virtualEl, element, { - placement: 'bottom-start', - strategy: 'fixed', - middleware: [offset(6), flip({ padding: 8 }), shift({ padding: 8 })], - }).then(({ x, y }) => { - if (!element.isConnected) return - element.style.left = `${x}px` - element.style.top = `${y}px` - }) -} - -function renderSlashSuggestion(): ReturnType> { - let component: ReactRenderer | null = null - let popup: HTMLElement | null = null - let boundEditor: Editor | null = null - let stopAutoUpdate: (() => void) | null = null - - const teardown = () => { - stopAutoUpdate?.() - stopAutoUpdate = null - boundEditor?.off('destroy', teardown) - boundEditor = null - popup?.remove() - component?.destroy() - popup = null - component = null - } - - return { - onStart: (props) => { - teardown() - component = new ReactRenderer(SlashCommandList, { props, editor: props.editor }) - popup = document.createElement('div') - popup.className = 'fixed top-0 left-0 z-[var(--z-popover)]' - popup.appendChild(component.element) - document.body.appendChild(popup) - boundEditor = props.editor - boundEditor.on('destroy', teardown) - const reference = { getBoundingClientRect: () => props.clientRect?.() ?? new DOMRect() } - const surface = popup - stopAutoUpdate = autoUpdate(reference, surface, () => - positionPopup(surface, props.clientRect) - ) - }, - onUpdate: (props) => { - component?.updateProps(props) - if (popup) positionPopup(popup, props.clientRect) - }, - onKeyDown: (props) => { - if (props.event.isComposing) return false - if (props.event.key === 'Escape') { - teardown() - return true - } - return component?.ref?.onKeyDown(props) ?? false - }, - onExit: teardown, - } -} - /** * Adds the `/` slash-command menu to the editor. Typing `/` at the start of a block — or after * whitespace — opens {@link SlashCommandList}; selecting an item runs its block transform. @@ -119,7 +51,13 @@ export const SlashCommand = Extension.create, SlashCommand const ctx: SlashCommandContext = { editor, range } props.run(ctx) }, - render: renderSlashSuggestion, + render: createSuggestionPopupRenderer({ + component: SlashCommandList, + mapProps: (props) => ({ + items: props.items as SlashCommandItem[], + command: props.command, + }), + }), }), ] }, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts index d50da96819a..245476d2d92 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useReducer, useRef } from 'react' +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useUpdateWorkspaceFileContent, @@ -36,6 +36,13 @@ interface UseEditableFileContentOptions { onDirtyChange?: (isDirty: boolean) => void onSaveStatusChange?: (status: SaveStatus) => void saveRef?: React.MutableRefObject<(() => Promise) | null> + /** + * Optional transform applied to the fetched content before it becomes the editor's baseline. A + * surface whose editor re-serializes its content to a canonical form (the rich markdown editor) + * passes its normalizer so an already-canonical file never reads as dirty on open. Applied only to + * the at-rest baseline, never while an agent stream is in flight. Stable reference required. + */ + normalizeBaseline?: (raw: string) => string } interface EditableFileContent { @@ -108,6 +115,7 @@ export function useEditableFileContent({ onDirtyChange, onSaveStatusChange, saveRef, + normalizeBaseline, }: UseEditableFileContentOptions): EditableFileContent { const onDirtyChangeRef = useRef(onDirtyChange) const onSaveStatusChangeRef = useRef(onSaveStatusChange) @@ -125,6 +133,24 @@ export function useEditableFileContent({ GENERATED_SOURCE_FILE_TYPES.has(file.type) ) + /** + * Latches once this mount has ever streamed (agent edit). A mount that streams keeps the raw fetched + * value as its baseline for its whole life, so normalization can never perturb the stream-reconcile + * comparisons in {@link syncTextEditorContentState}. A pure at-rest open never latches and normalizes + * freely. Set during render (not an effect) so it is observed before the baseline is derived. + */ + const everStreamedRef = useRef(false) + if (streamingContent !== undefined || isAgentEditing) everStreamedRef.current = true + + // Re-derived only when the fetched content changes (never on a stream-flag flip), so the dirty + // baseline stays stable through a post-stream reconcile. + const baselineContent = useMemo(() => { + if (fetchedContent === undefined || !normalizeBaseline || everStreamedRef.current) { + return fetchedContent + } + return normalizeBaseline(fetchedContent) + }, [fetchedContent, normalizeBaseline]) + const updateContent = useUpdateWorkspaceFileContent() const updateContentRef = useRef(updateContent) updateContentRef.current = updateContent @@ -138,7 +164,7 @@ export function useEditableFileContent({ markSavedContent, } = useFileContentState({ canReconcileToFetchedContent: file.key.length > 0, - fetchedContent, + fetchedContent: baselineContent, streamingContent, }) diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx index 7b2323c13ad..6f3a332db51 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx @@ -1,9 +1,8 @@ 'use client' -import type { ChangeEvent } from 'react' import { useCallback, useState } from 'react' import { getErrorMessage } from '@sim/utils/errors' -import { Chip, ChipInput, ChipModalField, ChipTextarea, Loader } from '@/components/emcn' +import { Chip, ChipInput, ChipModalField, Loader } from '@/components/emcn' import { requestJson } from '@/lib/api/client/request' import { importSkillContract } from '@/lib/api/contracts' import { @@ -31,15 +30,35 @@ function isAcceptedFile(file: File): boolean { } export function SkillImport({ onImport }: SkillImportProps) { - const [fileState, setFileState] = useState('idle') - const [fileError, setFileError] = useState('') - const [githubUrl, setGithubUrl] = useState('') const [githubState, setGithubState] = useState('idle') const [githubError, setGithubError] = useState('') - const [pasteContent, setPasteContent] = useState('') - const [pasteError, setPasteError] = useState('') + const [fileState, setFileState] = useState('idle') + const [fileError, setFileError] = useState('') + + const handleGithubImport = useCallback(async () => { + const trimmed = githubUrl.trim() + if (!trimmed) { + setGithubError('Please enter a GitHub URL') + setGithubState('error') + return + } + + setGithubState('loading') + setGithubError('') + + try { + const data = await requestJson(importSkillContract, { body: { url: trimmed } }) + const parsed = parseSkillMarkdown(data.content) + setGithubState('idle') + onImport(parsed) + } catch (err) { + const message = getErrorMessage(err, 'Failed to import from GitHub') + setGithubError(message) + setGithubState('error') + } + }, [githubUrl, onImport]) const processFile = useCallback( async (file: File) => { @@ -86,56 +105,8 @@ export function SkillImport({ onImport }: SkillImportProps) { [processFile] ) - const handleGithubImport = useCallback(async () => { - const trimmed = githubUrl.trim() - if (!trimmed) { - setGithubError('Please enter a GitHub URL') - setGithubState('error') - return - } - - setGithubState('loading') - setGithubError('') - - try { - const data = await requestJson(importSkillContract, { body: { url: trimmed } }) - const parsed = parseSkillMarkdown(data.content) - setGithubState('idle') - onImport(parsed) - } catch (err) { - const message = getErrorMessage(err, 'Failed to import from GitHub') - setGithubError(message) - setGithubState('error') - } - }, [githubUrl, onImport]) - - const handlePasteImport = useCallback(() => { - const trimmed = pasteContent.trim() - if (!trimmed) { - setPasteError('Please paste some content first') - return - } - - setPasteError('') - const parsed = parseSkillMarkdown(trimmed) - onImport(parsed) - }, [pasteContent, onImport]) - return (
- - - -
- -
- ) => { - setPasteContent(e.target.value) - if (pasteError) setPasteError('') - }} - resizable - className='min-h-[120px]' - /> -
- - Import - -
-
-
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx index ac9b21a6f02..8ef0e7e9edf 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx @@ -1,6 +1,7 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' +import dynamic from 'next/dynamic' import { useParams } from 'next/navigation' import { ChipModal, @@ -10,11 +11,25 @@ import { ChipModalFooter, ChipModalHeader, ChipModalTabs, + chipFieldSurfaceClass, } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' import { SkillImport } from '@/app/workspace/[workspaceId]/skills/components/skill-import' +import { parseSkillMarkdown } from '@/app/workspace/[workspaceId]/skills/components/utils' import type { SkillDefinition } from '@/hooks/queries/skills' import { useCreateSkill, useUpdateSkill } from '@/hooks/queries/skills' +const RichMarkdownField = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field' + ).then((m) => m.RichMarkdownField), + { + ssr: false, + loading: () =>
, + } +) + interface SkillModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -50,6 +65,9 @@ export function SkillModal({ const [name, setName] = useState('') const [description, setDescription] = useState('') const [content, setContent] = useState('') + // Bumped to remount the rich Content editor when content is set programmatically (a pasted + // SKILL.md is destructured into the fields) — the editor otherwise only seeds on mount. + const [contentSeed, setContentSeed] = useState(0) const [errors, setErrors] = useState({}) const [saving, setSaving] = useState(false) const [activeTab, setActiveTab] = useState('create') @@ -126,16 +144,27 @@ export function SkillModal({ } } - const handleImport = useCallback( - (data: { name: string; description: string; content: string }) => { - setName(data.name) - setDescription(data.description) - setContent(data.content) - setErrors({}) - setActiveTab('create') - }, - [] - ) + const applyImportedSkill = (data: { name: string; description: string; content: string }) => { + setName(data.name) + setDescription(data.description) + setContent(data.content) + setErrors({}) + setContentSeed((seed) => seed + 1) + } + + const handleImport = (data: { name: string; description: string; content: string }) => { + applyImportedSkill(data) + setActiveTab('create') + } + + /** Pasting a full SKILL.md (YAML frontmatter) into Content destructures it into the fields. */ + const handleContentPaste = (text: string): boolean => { + if (!text.trimStart().startsWith('---')) return false + const parsed = parseSkillMarkdown(text) + if (!parsed.name) return false + applyImportedSkill(parsed) + return true + } const isEditing = !!initialValues const readOnly = !!initialValues?.readOnly @@ -197,21 +226,23 @@ export function SkillModal({ error={errors.description} /> - { - setContent(value) - if (errors.content || errors.general) - setErrors((prev) => ({ ...prev, content: undefined, general: undefined })) - }} - placeholder='Skill instructions in markdown...' - minHeight={200} - resizable - required - error={errors.content} - /> + + { + setContent(value) + if (errors.content || errors.general) + setErrors((prev) => ({ ...prev, content: undefined, general: undefined })) + }} + placeholder='Skill instructions in markdown...' + minHeight={200} + disabled={readOnly || saving} + error={!!errors.content} + workspaceId={workspaceId} + onPasteText={handleContentPaste} + /> + {errors.general} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx index bfbf7b0cc73..bf31df3ec69 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx @@ -1,6 +1,8 @@ 'use client' import { useRef, useState } from 'react' +import dynamic from 'next/dynamic' +import { useParams } from 'next/navigation' import { ChipConfirmModal, ChipModal, @@ -9,12 +11,27 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + chipFieldSurfaceClass, } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' import { useGenerateVersionDescription, useUpdateDeploymentVersion, } from '@/hooks/queries/deployments' +const RichMarkdownField = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field' + ).then((m) => m.RichMarkdownField), + { + ssr: false, + loading: () =>
, + } +) + +const MAX_DESCRIPTION_LENGTH = 2000 + interface VersionDescriptionModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -32,6 +49,9 @@ export function VersionDescriptionModal({ versionName, currentDescription, }: VersionDescriptionModalProps) { + const params = useParams() + const workspaceId = params.workspaceId as string + const initialDescriptionRef = useRef(currentDescription || '') const [description, setDescription] = useState(initialDescriptionRef.current) const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) @@ -41,6 +61,7 @@ export function VersionDescriptionModal({ const hasChanges = description.trim() !== initialDescriptionRef.current.trim() const isGenerating = generateMutation.isPending + const isTooLong = description.length > MAX_DESCRIPTION_LENGTH const handleCloseAttempt = () => { if (updateMutation.isPending || isGenerating) { @@ -70,7 +91,7 @@ export function VersionDescriptionModal({ } const handleSave = () => { - if (!workflowId) return + if (!workflowId || isTooLong) return updateMutation.mutate( { @@ -96,21 +117,26 @@ export function VersionDescriptionModal({ handleCloseAttempt()}>Version Description {currentDescription ? 'Edit the' : 'Add a'} description for{' '} {versionName} } - value={description} - onChange={setDescription} - placeholder='Describe the changes in this deployment version...' - maxLength={2000} - minHeight={120} - disabled={isGenerating} - hint={`${description.length}/2000`} - /> + hint={`${description.length}/${MAX_DESCRIPTION_LENGTH}`} + > + MAX_DESCRIPTION_LENGTH} + workspaceId={workspaceId} + /> + {updateMutation.error?.message || generateMutation.error?.message} @@ -128,7 +154,7 @@ export function VersionDescriptionModal({ primaryAction={{ label: updateMutation.isPending ? 'Saving...' : 'Save', onClick: handleSave, - disabled: updateMutation.isPending || isGenerating || !hasChanges, + disabled: updateMutation.isPending || isGenerating || !hasChanges || isTooLong, }} /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx index eee59c4ece7..7e13011f960 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx @@ -15,6 +15,7 @@ import { } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' +import { formatVersionLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label' import { useUpdateDeploymentVersion } from '@/hooks/queries/deployments' import { VersionDescriptionModal } from './version-description-modal' @@ -72,7 +73,7 @@ export function Versions({ const handleStartRename = (version: number, currentName: string | null | undefined) => { setOpenDropdown(null) setEditingVersion(version) - setEditValue(currentName || `v${version}`) + setEditValue(currentName ?? '') } const handleSaveRename = (version: number) => { @@ -83,7 +84,7 @@ export function Versions({ } const currentVersion = versions.find((v) => v.version === version) - const currentName = currentVersion?.name || `v${version}` + const currentName = currentVersion?.name ?? '' if (editValue.trim() === currentName) { setEditingVersion(null) @@ -250,6 +251,7 @@ export function Versions({ }} onClick={(e) => e.stopPropagation()} onBlur={() => handleSaveRename(v.version)} + placeholder={`v${v.version}`} className={cn( 'h-auto w-full border-0 bg-transparent p-0 font-medium text-[var(--text-primary)] text-caption leading-5 shadow-none outline-none focus:outline-none focus-visible:ring-0' )} @@ -261,11 +263,16 @@ export function Versions({ spellCheck='false' /> ) : ( - - {v.name || `v${v.version}`} - {v.isActive && (live)} + + + v{v.version} + + {v.name && {v.name}} + {v.isActive && ( + (live) + )} {isSelected && ( - (selected) + (selected) )} )} @@ -364,9 +371,10 @@ export function Versions({ onOpenChange={(open) => !open && setDescriptionModalVersion(null)} workflowId={workflowId} version={descriptionModalVersionData.version} - versionName={ - descriptionModalVersionData.name || `v${descriptionModalVersionData.version}` - } + versionName={formatVersionLabel( + descriptionModalVersionData.version, + descriptionModalVersionData.name + )} currentDescription={descriptionModalVersionData.description} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label.ts new file mode 100644 index 00000000000..4cde3ae9a79 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label.ts @@ -0,0 +1,8 @@ +/** + * Formats a deployment version label so the numeric version is always a short, stable reference. + * Unnamed versions read as `v3`; named versions keep the number alongside the custom name (`v3 · My name`), + * so a long, truncated name never hides the shorthand a user can refer to. + */ +export function formatVersionLabel(version: number, name?: string | null): string { + return name ? `v${version} · ${name}` : `v${version}` +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx index 374115cc8fc..5e7a258b8cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx @@ -25,6 +25,7 @@ import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/w import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { Versions } from './components' +import { formatVersionLabel } from './format-version-label' const logger = createLogger('GeneralDeploy') @@ -198,7 +199,7 @@ export function GeneralDeploy({
@@ -210,7 +211,9 @@ export function GeneralDeploy({ > Live - {selectedVersionInfo?.name || `v${selectedVersion}`} + {selectedVersionInfo + ? formatVersionLabel(selectedVersionInfo.version, selectedVersionInfo.name) + : `v${selectedVersion}`}
@@ -281,7 +284,12 @@ export function GeneralDeploy({ title='Load Deployment' text={[ 'Are you sure you want to load ', - { text: versionToLoadInfo?.name || `v${versionToLoad?.version}`, bold: true }, + { + text: versionToLoadInfo + ? formatVersionLabel(versionToLoadInfo.version, versionToLoadInfo.name) + : `v${versionToLoad?.version}`, + bold: true, + }, '? ', { text: 'This will replace your current workflow with the deployed version.', @@ -302,7 +310,12 @@ export function GeneralDeploy({ title='Promote to live' text={[ 'Are you sure you want to promote ', - { text: versionToPromoteInfo?.name || `v${versionToPromote?.version}`, bold: true }, + { + text: versionToPromoteInfo + ? formatVersionLabel(versionToPromoteInfo.version, versionToPromoteInfo.name) + : `v${versionToPromote?.version}`, + bold: true, + }, ' to live? This version will become the active deployment and serve all API requests.', ]} confirm={{ @@ -318,7 +331,7 @@ export function GeneralDeploy({ {previewMode === 'selected' && selectedVersionInfo - ? selectedVersionInfo.name || `v${selectedVersion}` + ? formatVersionLabel(selectedVersionInfo.version, selectedVersionInfo.name) : 'Live Workflow'} From 230dc31eaa2ad0b52d9e9a473b4ac82940e34878 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 15:01:44 -0700 Subject: [PATCH 02/10] fix(rich-editor): address review feedback on modal field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RichMarkdownField reports the original value when the doc matches its canonical form, so a non-canonical input never reads as a false unsaved change (skill + version description modals) - Add sim: mention link navigation (Cmd/Ctrl-click) to the modal field - versions: keep the v{n} fallback as the rename guard/seed so re-submitting the displayed token is a no-op (no redundant "v3 · v3"); document the clear-name no-op - Clarify the lazy query-gating comment in useMarkdownMentions --- .../mention/use-markdown-mentions.ts | 4 ++- .../rich-markdown-field.tsx | 27 +++++++++++++++++-- .../general/components/versions.tsx | 7 ++--- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts index d75ed04456c..175b940679a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts @@ -20,7 +20,9 @@ export function useMarkdownMentions( options: { enabled: boolean } ): MentionItem[] { const active = options.enabled && Boolean(workspaceId) - // Pass through only when active; each hook self-gates on a falsy workspaceId. + // When inactive, `ws` is undefined and `wsStr` is '' so every query stays disabled until the first + // `@`: the hooks that expose an `enabled` option get it explicitly; the rest (which take no options) + // self-gate internally on the falsy workspaceId — both empty string and undefined read as disabled. const ws = active ? workspaceId : undefined const wsStr = ws ?? '' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 92af6e2a49d..0da2a492708 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react' import type { JSONContent } from '@tiptap/core' import { EditorContent, useEditor } from '@tiptap/react' +import { useRouter } from 'next/navigation' import { chipFieldSurfaceClass } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { createMarkdownEditorExtensions } from './extensions' @@ -12,9 +13,10 @@ import { splitFrontmatter, } from './markdown-fidelity' import { parseMarkdownToDoc } from './markdown-parse' -import { useEditorMentions } from './mention' +import { parseSimHref, simLinkPath, useEditorMentions } from './mention' import { EditorBubbleMenu } from './menus/bubble-menu' import { LinkHoverCard } from './menus/link-hover-card' +import { normalizeMarkdownContent } from './normalize-content' import '@/components/emcn/components/code/code.css' import './rich-markdown-editor.css' @@ -64,6 +66,9 @@ export function RichMarkdownField({ onPasteText, }: RichMarkdownFieldProps) { const containerRef = useRef(null) + const router = useRouter() + const routerRef = useRef(router) + routerRef.current = router // Frontmatter is held out-of-band and re-attached on serialize, exactly like the file editor. // Split once at mount — the refs and the seed doc all derive from the initial value. @@ -76,6 +81,12 @@ export function RichMarkdownField({ const onPasteTextRef = useRef(onPasteText) onPasteTextRef.current = onPasteText + // The original value verbatim, plus its canonical serialization. The editor only ever emits canonical + // markdown, so an already-non-canonical input would re-serialize on mount and read as an unsaved edit; + // reporting the original when the doc matches its canonical form keeps the field clean until a real edit. + const initialValueRef = useRef(value) + const [canonicalSeed] = useState(() => normalizeMarkdownContent(value)) + // TipTap extensions are stateful — build them once per mount so each field gets its own placeholder. const [extensions] = useState(() => createMarkdownEditorExtensions({ placeholder })) const [initialContent] = useState(() => parseMarkdownToDoc(initialSplit.body)) @@ -96,11 +107,23 @@ export function RichMarkdownField({ if (!text) return false return handler(text) }, + handleClick: (view, _pos, event) => { + // Cmd/Ctrl-click an `@`-mention link to navigate to the resource (a plain click places the caret). + const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') + if (!href?.startsWith('sim:') || !workspaceId) return false + if (view.editable && !(event.metaKey || event.ctrlKey)) return false + const parsed = parseSimHref(href) + const path = parsed && simLinkPath(workspaceId, parsed.kind, parsed.id) + if (!path) return false + routerRef.current.push(path) + return true + }, }, onUpdate: ({ editor }) => { const md = postProcessSerializedMarkdown(editor.getMarkdown()) lastSyncedBodyRef.current = md - onChangeRef.current(applyFrontmatter(frontmatterRef.current, md)) + const serialized = applyFrontmatter(frontmatterRef.current, md) + onChangeRef.current(serialized === canonicalSeed ? initialValueRef.current : serialized) }, }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx index 7e13011f960..055d3b0ae48 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx @@ -73,18 +73,20 @@ export function Versions({ const handleStartRename = (version: number, currentName: string | null | undefined) => { setOpenDropdown(null) setEditingVersion(version) - setEditValue(currentName ?? '') + setEditValue(currentName || `v${version}`) } const handleSaveRename = (version: number) => { if (renameMutation.isPending) return + // Clearing the name is a no-op — the version number is always the canonical reference. if (!workflowId || !editValue.trim()) { setEditingVersion(null) return } const currentVersion = versions.find((v) => v.version === version) - const currentName = currentVersion?.name ?? '' + // Compare against the `v{n}` fallback so re-submitting the displayed token saves no redundant name. + const currentName = currentVersion?.name || `v${version}` if (editValue.trim() === currentName) { setEditingVersion(null) @@ -251,7 +253,6 @@ export function Versions({ }} onClick={(e) => e.stopPropagation()} onBlur={() => handleSaveRename(v.version)} - placeholder={`v${v.version}`} className={cn( 'h-auto w-full border-0 bg-transparent p-0 font-medium text-[var(--text-primary)] text-caption leading-5 shadow-none outline-none focus:outline-none focus-visible:ring-0' )} From 790a114bbfb3935e1208e81a6ea2fa17478a5322 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 15:21:22 -0700 Subject: [PATCH 03/10] fix(skills): re-seed Content editor when initialValues changes Bump the field's remount key in the reset guard so the seed-once rich editor re-seeds when content is reset from a changed initialValues (same skill id keeps the React key otherwise stable), keeping the editor and saved value in sync. --- .../skills/components/skill-modal/skill-modal.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx index 8ef0e7e9edf..39dffab5ee4 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx @@ -65,8 +65,8 @@ export function SkillModal({ const [name, setName] = useState('') const [description, setDescription] = useState('') const [content, setContent] = useState('') - // Bumped to remount the rich Content editor when content is set programmatically (a pasted - // SKILL.md is destructured into the fields) — the editor otherwise only seeds on mount. + // Bumped to remount the seed-once rich Content editor whenever `content` is set programmatically — + // a reset from a changed `initialValues` or a destructured SKILL.md paste — so the editor re-seeds. const [contentSeed, setContentSeed] = useState(0) const [errors, setErrors] = useState({}) const [saving, setSaving] = useState(false) @@ -80,6 +80,9 @@ export function SkillModal({ setContent(initialValues?.content ?? '') setErrors({}) setActiveTab('create') + // Remount the seed-once Content editor so it re-seeds from the reset value (an `initialValues` + // change for the same skill keeps the React key otherwise stable). + setContentSeed((seed) => seed + 1) } if (open !== prevOpen) setPrevOpen(open) if (initialValues !== prevInitialValues) setPrevInitialValues(initialValues) From 269308946c5eb774001121bbc939e7059ea005ac Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 16:17:50 -0700 Subject: [PATCH 04/10] 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 --- .../rich-markdown-editor/extensions.ts | 3 +- .../rich-markdown-editor/mention/index.ts | 1 + .../mention/mention-icon.ts | 26 ++++ .../mention/mention-node.test.ts | 43 +++++++ .../mention/mention-node.tsx | 121 ++++++++++++++++++ .../rich-markdown-editor/mention/mention.ts | 4 +- .../mention/use-markdown-mentions.ts | 28 ++-- .../menus/suggestion-menu-chrome.ts | 12 +- .../rich-markdown-editor.tsx | 10 +- .../rich-markdown-field.tsx | 17 +-- .../components/version-description-modal.tsx | 7 +- apps/sim/lib/api/contracts/deployments.ts | 7 +- 12 files changed, 228 insertions(+), 51 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index defb1332247..fa8917937a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -17,7 +17,7 @@ import { MarkdownImage, ResizableImage } from './image' import { RichMarkdownKeymap } from './keymap' import { MarkdownLinkInputRule } from './link-input-rule' import { MarkdownPaste } from './markdown-paste' -import { Mention, SIM_LINK_SCHEME } from './mention' +import { MarkdownMention, Mention, MentionChip, SIM_LINK_SCHEME } from './mention' import { SlashCommand } from './slash-command/slash-command' /** @@ -94,6 +94,7 @@ export function createMarkdownContentExtensions({ InlineCode, codeBlock, (nodeViews ? ResizableImage : MarkdownImage).configure({ allowBase64: true }), + nodeViews ? MentionChip : MarkdownMention, TaskList, TaskItem.configure({ nested: true }), PipeSafeTable.configure({ resizable: true }), diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts index 361f282333d..d5350cab508 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts @@ -1,4 +1,5 @@ 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' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts new file mode 100644 index 00000000000..9537e54ec50 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts @@ -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, 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] +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts new file mode 100644 index 00000000000..cb5797d7d47 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts @@ -0,0 +1,43 @@ +/** + * @vitest-environment jsdom + * + * The `@`-mention is stored as a portable `[label](sim:/)` 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() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx new file mode 100644 index 00000000000..ac942dc43b3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx @@ -0,0 +1,121 @@ +import type { MouseEvent } from 'react' +import type { JSONContent, MarkdownToken } from '@tiptap/core' +import { Node } from '@tiptap/core' +import type { ReactNodeViewProps } from '@tiptap/react' +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' +import { useParams, useRouter } from 'next/navigation' +import { cn } from '@/lib/core/utils/cn' +import { mentionIcon } from './mention-icon' +import { simLinkPath, toSimHref } from './sim-link' +import type { MentionKind } from './types' + +interface MentionAttrs { + kind: MentionKind + id: string + label: string +} + +/** The markdown form of a mention — the chat's portable `[label](sim:/)` link. */ +const MENTION_MD_RE = /^\[([^\]]+)\]\(sim:([a-z_]+)\/([^)\s]+)\)/ + +/** Custom fields the mention tokenizer hangs on the marked token (all optional, like the image token). */ +interface MentionTokenFields { + label?: string + kind?: string + id?: string +} + +/** + * Inline atom node for an `@`-mention. Renders (live) as a chip with the entity's icon, but serializes + * to the portable `[label](sim:/)` markdown link — so the saved content is identical to a + * plain link (agent-readable, round-trips through the chat's `chip-clipboard-codec`) while the editor + * shows it as a chip rather than a blue link. Shared by the headless round-trip path (no node view) + * and the live {@link MentionChip}, mirroring the image node's split. + */ +export const MarkdownMention = Node.create({ + name: 'mention', + inline: true, + group: 'inline', + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + kind: { default: '' }, + id: { default: '' }, + label: { default: '' }, + } + }, + + parseHTML() { + return [ + { + tag: 'span[data-mention]', + getAttrs: (element) => ({ + kind: element.getAttribute('data-kind') ?? '', + id: element.getAttribute('data-id') ?? '', + label: element.textContent ?? '', + }), + }, + ] + }, + + renderHTML({ node }) { + const { kind, id, label } = node.attrs as MentionAttrs + return ['span', { 'data-mention': '', 'data-kind': kind, 'data-id': id }, label] + }, + + markdownTokenizer: { + name: 'mention', + level: 'inline' as const, + start: (src: string) => src.indexOf('['), + tokenize: (src: string): (MentionTokenFields & { type: string; raw: string }) | undefined => { + const match = MENTION_MD_RE.exec(src) + if (!match) return undefined + return { type: 'mention', raw: match[0], label: match[1], kind: match[2], id: match[3] } + }, + }, + parseMarkdown: (token: MarkdownToken): JSONContent => { + const { kind, id, label } = token as MentionTokenFields + return { type: 'mention', attrs: { kind: kind ?? '', id: id ?? '', label: label ?? '' } } + }, + renderMarkdown: (node: JSONContent): string => { + const { kind, id, label } = (node.attrs ?? {}) as MentionAttrs + return `[${label}](${toSimHref(kind, id)})` + }, +}) + +const CHIP_CLASS = + '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)]' + +/** Live chip: the entity icon + label. Cmd/Ctrl-click navigates to the resource. */ +function MentionChipView({ node }: ReactNodeViewProps) { + const router = useRouter() + const params = useParams() + const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined + const { kind, id, label } = node.attrs as MentionAttrs + const Icon = mentionIcon(kind, id) + + const handleClick = (event: MouseEvent) => { + if (!(event.metaKey || event.ctrlKey) || !workspaceId) return + const path = simLinkPath(workspaceId, kind, id) + if (!path) return + event.preventDefault() + router.push(path) + } + + return ( + + {Icon && } + {label} + + ) +} + +/** Live mention node with the chip view; same schema + markdown output as the headless one. */ +export const MentionChip = MarkdownMention.extend({ + addNodeView() { + return ReactNodeViewRenderer(MentionChipView) + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts index 498a3212197..db35b0dfc01 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts @@ -4,7 +4,6 @@ import Suggestion from '@tiptap/suggestion' import { createSuggestionPopupRenderer } from '../menus/suggestion-popup' import { MentionList } from './mention-list' import { createMentionStore, type MentionStore } from './mention-store' -import { toSimHref } from './sim-link' import type { MentionItem } from './types' /** 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, MentionStorage>({ // Items are sourced reactively from the store inside MentionList; this only gates the plugin. items: () => [], command: ({ editor, range, props }) => { - const href = toSimHref(props.kind, props.id) editor .chain() .focus() .deleteRange(range) .insertContent([ - { type: 'text', text: props.label, marks: [{ type: 'link', attrs: { href } }] }, + { type: 'mention', attrs: { kind: props.kind, id: props.id, label: props.label } }, { type: 'text', text: ' ' }, ]) .run() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts index 175b940679a..023faacc43c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts @@ -1,5 +1,4 @@ import { useMemo } from 'react' -import { Database, File, Folder, Sparkles, Table, Workflow } from 'lucide-react' import { listIntegrations } from '@/blocks/integration-matcher' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useSkills } from '@/hooks/queries/skills' @@ -7,6 +6,7 @@ import { useTablesList } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { mentionIcon } from './mention-icon' import type { MentionItem } from './types' /** @@ -41,7 +41,7 @@ export function useMarkdownMentions( id: integration.blockType, label: integration.name, group: 'Integrations', - icon: integration.icon, + icon: mentionIcon('integration', integration.blockType), })) }, [active]) @@ -50,24 +50,36 @@ export function useMarkdownMentions( const items: MentionItem[] = [] for (const file of files.data ?? []) - items.push({ kind: 'file', id: file.id, label: file.name, group: 'Files', icon: File }) + items.push({ + kind: 'file', + id: file.id, + label: file.name, + group: 'Files', + icon: mentionIcon('file', file.id), + }) for (const folder of folders.data ?? []) items.push({ kind: 'folder', id: folder.id, label: folder.name, group: 'Folders', - icon: Folder, + icon: mentionIcon('folder', folder.id), }) for (const table of tables.data ?? []) - items.push({ kind: 'table', id: table.id, label: table.name, group: 'Tables', icon: Table }) + items.push({ + kind: 'table', + id: table.id, + label: table.name, + group: 'Tables', + icon: mentionIcon('table', table.id), + }) for (const kb of knowledgeBases.data ?? []) items.push({ kind: 'knowledge', id: kb.id, label: kb.name, group: 'Knowledge bases', - icon: Database, + icon: mentionIcon('knowledge', kb.id), }) for (const workflow of workflows.data ?? []) items.push({ @@ -75,7 +87,7 @@ export function useMarkdownMentions( id: workflow.id, label: workflow.name, group: 'Workflows', - icon: Workflow, + icon: mentionIcon('workflow', workflow.id), }) for (const skill of skills.data ?? []) items.push({ @@ -83,7 +95,7 @@ export function useMarkdownMentions( id: skill.id, label: skill.name, group: 'Skills', - icon: Sparkles, + icon: mentionIcon('skill', skill.id), }) items.push(...integrationItems) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts index 76ebf4b2acd..1d24fbee07b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts @@ -4,13 +4,15 @@ * class strings per consumer. */ -/** The floating panel: bordered card with the enter animation. */ +/** The floating panel: bordered card with the enter animation, width-capped like the chat mention menu. */ export const SUGGESTION_SURFACE_CLASS = - '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' + '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' -/** A scrollable list body (hidden scrollbar), added alongside {@link SUGGESTION_SURFACE_CLASS}. */ -export const SUGGESTION_SCROLL_CLASS = - 'max-h-[330px] scroll-py-1.5 overflow-y-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' +/** + * A scrollable list body, added alongside {@link SUGGESTION_SURFACE_CLASS}. Caps the height and scrolls + * — matching the chat composer's `@` menu — so a long workspace list never overflows its container. + */ +export const SUGGESTION_SCROLL_CLASS = 'max-h-[240px] scroll-py-1.5 overflow-y-auto overscroll-none' /** A selectable row: icon + label, 14px icon in `--text-icon`, truncating label. */ export const SUGGESTION_ITEM_CLASS = diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 3d1b18619a7..ba64b9ddda2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -21,7 +21,7 @@ import { splitFrontmatter, } from './markdown-fidelity' import { parseMarkdownToDoc } from './markdown-parse' -import { parseSimHref, simLinkPath, useEditorMentions } from './mention' +import { useEditorMentions } from './mention' import { EditorBubbleMenu } from './menus/bubble-menu' import { LinkHoverCard } from './menus/link-hover-card' import { normalizeMarkdownContent } from './normalize-content' @@ -261,14 +261,6 @@ export function LoadedRichMarkdownEditor({ }) return true } - // A `@`-mention link (`sim:/`) navigates to the referenced resource in-app. - if (href.startsWith('sim:')) { - const parsed = parseSimHref(href) - const path = parsed && simLinkPath(workspaceId, parsed.kind, parsed.id) - if (!path) return false - routerRef.current.push(path) - return true - } const normalized = normalizeLinkHref(href) if (!normalized) return false // A same-origin in-app path navigates within the SPA (same tab); external URLs open a new tab. diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 0da2a492708..bb497f7500a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from 'react' import type { JSONContent } from '@tiptap/core' import { EditorContent, useEditor } from '@tiptap/react' -import { useRouter } from 'next/navigation' import { chipFieldSurfaceClass } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { createMarkdownEditorExtensions } from './extensions' @@ -13,7 +12,7 @@ import { splitFrontmatter, } from './markdown-fidelity' import { parseMarkdownToDoc } from './markdown-parse' -import { parseSimHref, simLinkPath, useEditorMentions } from './mention' +import { useEditorMentions } from './mention' import { EditorBubbleMenu } from './menus/bubble-menu' import { LinkHoverCard } from './menus/link-hover-card' import { normalizeMarkdownContent } from './normalize-content' @@ -66,9 +65,6 @@ export function RichMarkdownField({ onPasteText, }: RichMarkdownFieldProps) { const containerRef = useRef(null) - const router = useRouter() - const routerRef = useRef(router) - routerRef.current = router // Frontmatter is held out-of-band and re-attached on serialize, exactly like the file editor. // Split once at mount — the refs and the seed doc all derive from the initial value. @@ -107,17 +103,6 @@ export function RichMarkdownField({ if (!text) return false return handler(text) }, - handleClick: (view, _pos, event) => { - // Cmd/Ctrl-click an `@`-mention link to navigate to the resource (a plain click places the caret). - const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') - if (!href?.startsWith('sim:') || !workspaceId) return false - if (view.editable && !(event.metaKey || event.ctrlKey)) return false - const parsed = parseSimHref(href) - const path = parsed && simLinkPath(workspaceId, parsed.kind, parsed.id) - if (!path) return false - routerRef.current.push(path) - return true - }, }, onUpdate: ({ editor }) => { const md = postProcessSerializedMarkdown(editor.getMarkdown()) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx index bf31df3ec69..7d22794ab9a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx @@ -30,7 +30,8 @@ const RichMarkdownField = dynamic( } ) -const MAX_DESCRIPTION_LENGTH = 2000 +/** A high cap that only guards against abuse — no visible counter; normal descriptions never reach it. */ +const MAX_DESCRIPTION_LENGTH = 50_000 interface VersionDescriptionModalProps { open: boolean @@ -124,13 +125,13 @@ export function VersionDescriptionModal({ {versionName} } - hint={`${description.length}/${MAX_DESCRIPTION_LENGTH}`} > MAX_DESCRIPTION_LENGTH} diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index 18ae97ecd15..1b9cd57db72 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -36,12 +36,7 @@ export const deploymentVersionMetadataFieldsSchema = z.object({ .min(1, 'Name cannot be empty') .max(100, 'Name must be 100 characters or less') .optional(), - description: z - .string() - .trim() - .max(2000, 'Description must be 2000 characters or less') - .nullable() - .optional(), + description: z.string().trim().max(50_000, 'Description is too long').nullable().optional(), }) export const updateDeploymentVersionMetadataBodySchema = deploymentVersionMetadataFieldsSchema.refine( From b36d031061e6a0a5b9f5905dc5887e40058f8265 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 16:49:27 -0700 Subject: [PATCH 05/10] fix(rich-editor): make suggestion menus scrollable inside modals - Mount the slash/@ menu popup inside the host dialog (when present) instead of document.body: Radix's scroll-lock blocks wheel events outside the dialog subtree, so a body-level popup couldn't scroll in a modal. position:fixed keeps it viewport-positioned (the modal centers via flex, no transform) so it isn't clipped - Fix the invalid max-w arbitrary value (calc needs spaces) that left the menu uncapped - Match the version-description editor's dynamic-import loading height to the field so the modal doesn't grow when the chunk loads --- .../rich-markdown-editor/menus/suggestion-menu-chrome.ts | 2 +- .../rich-markdown-editor/menus/suggestion-popup.ts | 6 +++++- .../general/components/version-description-modal.tsx | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts index 1d24fbee07b..c468042a18d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts @@ -6,7 +6,7 @@ /** The floating panel: bordered card with the enter animation, width-capped like the chat mention menu. */ export const SUGGESTION_SURFACE_CLASS = - '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' + 'min-w-[220px] max-w-[320px] 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' /** * A scrollable list body, added alongside {@link SUGGESTION_SURFACE_CLASS}. Caps the height and scrolls diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts index 66b27bf61c1..7457ef517e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-popup.ts @@ -72,7 +72,11 @@ export function createSuggestionPopupRenderer popup = document.createElement('div') popup.className = 'fixed top-0 left-0 z-[var(--z-popover)]' popup.appendChild(component.element) - document.body.appendChild(popup) + // Mount inside the host dialog when the editor is in a modal: Radix's scroll-lock blocks wheel + // events outside the dialog subtree, so a body-level popup can't be scrolled. `position: fixed` + // keeps it viewport-positioned (the modal centers via flex, no transform) so it isn't clipped. + const host = props.editor.view.dom.closest('[role="dialog"]') ?? document.body + host.appendChild(popup) boundEditor = props.editor boundEditor.on('destroy', teardown) const reference = { getBoundingClientRect: () => props.clientRect?.() ?? new DOMRect() } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx index 7d22794ab9a..00896a0a1a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx @@ -26,7 +26,7 @@ const RichMarkdownField = dynamic( ).then((m) => m.RichMarkdownField), { ssr: false, - loading: () =>
, + loading: () =>
, } ) From 69c271f493d03d2401d60cabe6b27b9cf2e095dc Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 18:51:30 -0700 Subject: [PATCH 06/10] fix(rich-editor): escape bracketed mention labels + disable images in field editors - Escape/unescape `[`/`]` in mention labels so an entity named e.g. `data[1].csv` round-trips into a chip instead of degrading to a plain link - Hide the `/Image` command where image upload isn't wired (the skill + version description field editors), so images can't be inserted there; the file viewer keeps image support --- .../mention/mention-node.test.ts | 8 ++++++ .../mention/mention-node.tsx | 25 ++++++++++++++++--- .../slash-command/commands.test.ts | 10 ++++++++ .../slash-command/commands.ts | 16 ++++++++---- .../slash-command/slash-command.ts | 7 +++++- 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts index cb5797d7d47..ec6447ff88b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts @@ -36,6 +36,14 @@ describe('mention node round-trip', () => { } }) + it('round-trips a label containing brackets (e.g. a bracketed file name) as a chip', () => { + const input = '[data\\[1\\].csv](sim:file/abc)' + const doc = parseMarkdownToDoc(input) + const mention = findMention(doc) + expect(mention?.attrs).toEqual({ kind: 'file', id: 'abc', label: 'data[1].csv' }) + 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() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx index ac942dc43b3..248bb4a938d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx @@ -15,8 +15,22 @@ interface MentionAttrs { label: string } -/** The markdown form of a mention — the chat's portable `[label](sim:/)` link. */ -const MENTION_MD_RE = /^\[([^\]]+)\]\(sim:([a-z_]+)\/([^)\s]+)\)/ +/** + * The markdown form of a mention — the chat's portable `[label](sim:/)` link. The label + * group accepts backslash-escaped characters so a label containing `[`/`]` (e.g. a file named + * `data[1].csv`) still round-trips into a chip instead of degrading to a plain link. + */ +const MENTION_MD_RE = /^\[((?:\\.|[^\]\\])+)\]\(sim:([a-z_]+)\/([^)\s]+)\)/ + +/** Escape `\`, `[`, `]` in a mention label so brackets in entity names can't break the link syntax. */ +function escapeLabel(label: string): string { + return label.replace(/[\\[\]]/g, '\\$&') +} + +/** Inverse of {@link escapeLabel}, applied when parsing a mention back from markdown. */ +function unescapeLabel(label: string): string { + return label.replace(/\\([\\[\]])/g, '$1') +} /** Custom fields the mention tokenizer hangs on the marked token (all optional, like the image token). */ interface MentionTokenFields { @@ -78,11 +92,14 @@ export const MarkdownMention = Node.create({ }, parseMarkdown: (token: MarkdownToken): JSONContent => { const { kind, id, label } = token as MentionTokenFields - return { type: 'mention', attrs: { kind: kind ?? '', id: id ?? '', label: label ?? '' } } + return { + type: 'mention', + attrs: { kind: kind ?? '', id: id ?? '', label: unescapeLabel(label ?? '') }, + } }, renderMarkdown: (node: JSONContent): string => { const { kind, id, label } = (node.attrs ?? {}) as MentionAttrs - return `[${label}](${toSimHref(kind, id)})` + return `[${escapeLabel(label)}](${toSimHref(kind, id)})` }, }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts index 634c85e3a1b..90b74adf7ed 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.test.ts @@ -32,6 +32,16 @@ describe('filterSlashCommands', () => { it('returns empty for no match', () => { expect(filterSlashCommands('zzz')).toEqual([]) }) + + it('drops the Image command when image insertion is disallowed', () => { + expect(filterSlashCommands('', { allowImages: false }).map((c) => c.title)).not.toContain( + 'Image' + ) + expect(filterSlashCommands('image', { allowImages: false })).toEqual([]) + expect(filterSlashCommands('image', { allowImages: true }).map((c) => c.title)).toContain( + 'Image' + ) + }) }) describe('SLASH_COMMANDS registry', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts index a3bdd960bc8..9399e91f4e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts @@ -156,13 +156,19 @@ export const SLASH_COMMANDS: readonly SlashCommandItem[] = [ ] /** - * Filters commands by a case-insensitive match against title or aliases. Order is - * preserved so the menu stays stable as the query narrows. + * Filters commands by a case-insensitive match against title or aliases. Order is preserved so the + * menu stays stable as the query narrows. The Image command is dropped when image insertion isn't + * available (`allowImages: false`) — e.g. the modal field editors, which have no upload affordance. */ -export function filterSlashCommands(query: string): SlashCommandItem[] { +export function filterSlashCommands( + query: string, + options?: { allowImages?: boolean } +): SlashCommandItem[] { + const allowImages = options?.allowImages ?? true + const available = allowImages ? SLASH_COMMANDS : SLASH_COMMANDS.filter((c) => c.title !== 'Image') const q = query.trim().toLowerCase() - if (!q) return [...SLASH_COMMANDS] - return SLASH_COMMANDS.filter( + if (!q) return [...available] + return available.filter( (command) => command.title.toLowerCase().includes(q) || command.aliases.some((alias) => alias.includes(q)) ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts index 737317a3f08..6b885a7da36 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts @@ -46,7 +46,12 @@ export const SlashCommand = Extension.create, SlashCommand if ($from.parentOffset === 0) return true return /\s/.test($from.parent.textBetween($from.parentOffset - 1, $from.parentOffset)) }, - items: ({ query }) => filterSlashCommands(query), + // The Image command is offered only where image upload is wired (the file viewer); the modal + // field editors never set `insertImage`, so `@`-style image insertion is hidden there. + items: ({ editor, query }) => + filterSlashCommands(query, { + allowImages: editor.storage.slashCommand.insertImage != null, + }), command: ({ editor, range, props }) => { const ctx: SlashCommandContext = { editor, range } props.run(ctx) From 91b6a41451d1c654e89c42d1ddc7f734a6207ea2 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 19:19:23 -0700 Subject: [PATCH 07/10] fix(rich-editor): keep suggestion keyboard nav working after async items load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suggestion plugin captures the list's onKeyDown handle via ReactRenderer.ref once at mount. The mention list's items arrive asynchronously from the workspace store, so the captured handle closed over an empty `flat` and returned false for arrow/enter — letting the editor move the caret instead of navigating the menu. Read live values through a ref so the mount-time handle always sees current items/activeIndex. Hardened the slash list the same way. --- .../mention/mention-list.tsx | 51 +++++++++++-------- .../slash-command/slash-command-list.tsx | 50 ++++++++++-------- 2 files changed, 61 insertions(+), 40 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx index 2e892ff4565..7814fa6ce9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx @@ -90,26 +90,37 @@ export const MentionList = forwardRef(funct ?.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 - }, - })) + // The suggestion plugin captures this handle via `ReactRenderer.ref` once at mount, so it must read + // live values rather than close over them — otherwise keyboard nav uses the initial (empty) `flat` + // from before the async workspace data landed, and arrow keys fall through to the editor. + const latest = useRef({ flat, activeIndex, command }) + latest.current = { flat, activeIndex, command } + + useImperativeHandle( + ref, + () => ({ + onKeyDown: ({ event }) => { + const { flat, activeIndex, command } = latest.current + 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 ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx index ad80e4ff74a..2d5abdfb9e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx @@ -37,26 +37,36 @@ export const SlashCommandList = forwardRef ({ - onKeyDown: ({ event }) => { - if (items.length === 0) return false - if (event.key === 'ArrowUp') { - setActiveIndex((i) => (i + items.length - 1) % items.length) - return true - } - if (event.key === 'ArrowDown') { - setActiveIndex((i) => (i + 1) % items.length) - return true - } - if (event.key === 'Enter') { - const item = items[activeIndex] - if (!item) return false - command(item) - return true - } - return false - }, - })) + // Read live values: the suggestion plugin captures this handle via `ReactRenderer.ref` at mount, + // so closing over `items`/`activeIndex` would make Enter act on the mount-time snapshot. + const latest = useRef({ items, activeIndex, command }) + latest.current = { items, activeIndex, command } + + useImperativeHandle( + ref, + () => ({ + onKeyDown: ({ event }) => { + const { items, activeIndex, command } = latest.current + if (items.length === 0) return false + if (event.key === 'ArrowUp') { + setActiveIndex((i) => (i + items.length - 1) % items.length) + return true + } + if (event.key === 'ArrowDown') { + setActiveIndex((i) => (i + 1) % items.length) + return true + } + if (event.key === 'Enter') { + const item = items[activeIndex] + if (!item) return false + command(item) + return true + } + return false + }, + }), + [] + ) const groups = useMemo(() => { const ordered: { group: string; items: { item: SlashCommandItem; index: number }[] }[] = [] From 5fabce4a197dc79b8f55cb0ec4fe019408d1d0bb Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 19:32:34 -0700 Subject: [PATCH 08/10] test(rich-editor): cover suggestion keyboard nav through ReactRenderer; drop inline comments Adds a test that drives the real ReactRenderer path the suggestion plugin uses: the captured onKeyDown handle returns false while the store is empty and true once async workspace items land, and arrow+enter select the right item. Removes the explanatory inline comments from the two imperative handles. --- .../mention/mention-list.test.tsx | 96 +++++++++++++++++++ .../mention/mention-list.tsx | 3 - .../slash-command/slash-command-list.tsx | 2 - 3 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx new file mode 100644 index 00000000000..19ef5504a84 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx @@ -0,0 +1,96 @@ +/** + * @vitest-environment jsdom + * + * Guards the `@` menu's keyboard navigation against the async-data race: the suggestion plugin grabs + * the list's `onKeyDown` handle once, but workspace items arrive later via the store. The handle must + * read live values so arrow/enter work after the data lands (otherwise keys fall through to the editor). + * The second test drives the real `ReactRenderer` path the suggestion plugin actually uses. + */ +import { act, createRef } from 'react' +import { Editor } from '@tiptap/core' +import { EditorContent, ReactRenderer } from '@tiptap/react' +import { File } from 'lucide-react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMarkdownEditorExtensions } from '../extensions' +import { MentionList, type MentionListHandle } from './mention-list' +import { createMentionStore } from './mention-store' +import type { MentionItem } from './types' + +const items: MentionItem[] = [ + { kind: 'file', id: 'a', label: 'Alpha', group: 'Files', icon: File }, + { kind: 'file', id: 'b', label: 'Beta', group: 'Files', icon: File }, +] + +const arrowDown = { event: new KeyboardEvent('keydown', { key: 'ArrowDown' }) } +const enter = { event: new KeyboardEvent('keydown', { key: 'Enter' }) } + +describe('MentionList keyboard nav', () => { + let container: HTMLElement + let root: ReturnType + + beforeEach(async () => { + // jsdom implements neither — both are exercised by scroll-into-view and ProseMirror. + Element.prototype.scrollIntoView = vi.fn() + document.elementFromPoint = vi.fn(() => null) + const { createRoot } = await import('react-dom/client') + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it('navigates with arrows + inserts on enter once async items have loaded', () => { + const ref = createRef() + const command = vi.fn() + const store = createMentionStore() + + // Menu opens before the workspace data resolves — the store is still empty. + act(() => { + root.render() + }) + expect(ref.current?.onKeyDown(arrowDown)).toBe(false) + + // Async data lands; the captured handle must now see the items and intercept the keys. + act(() => store.set(items)) + + let handled: boolean | undefined + act(() => { + handled = ref.current?.onKeyDown(arrowDown) + }) + expect(handled).toBe(true) + + act(() => { + ref.current?.onKeyDown(enter) + }) + expect(command).toHaveBeenCalledWith(items[1]) + }) + + it('exposes a working onKeyDown through ReactRenderer (the suggestion plugin path)', async () => { + const editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }) }) + act(() => { + root.render() + }) + + const command = vi.fn() + const store = createMentionStore() + const renderer = new ReactRenderer(MentionList, { + editor, + props: { query: '', command, store }, + }) + // Let the portal mount so ReactRenderer captures the imperative handle. + await act(async () => {}) + + expect(renderer.ref).not.toBeNull() + expect(renderer.ref?.onKeyDown(arrowDown)).toBe(false) + + act(() => store.set(items)) + expect(renderer.ref?.onKeyDown(arrowDown)).toBe(true) + + renderer.destroy() + editor.destroy() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx index 7814fa6ce9c..97cedfdc0a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx @@ -90,9 +90,6 @@ export const MentionList = forwardRef(funct ?.scrollIntoView({ block: 'nearest' }) }, [activeIndex]) - // The suggestion plugin captures this handle via `ReactRenderer.ref` once at mount, so it must read - // live values rather than close over them — otherwise keyboard nav uses the initial (empty) `flat` - // from before the async workspace data landed, and arrow keys fall through to the editor. const latest = useRef({ flat, activeIndex, command }) latest.current = { flat, activeIndex, command } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx index 2d5abdfb9e8..2243038b2f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx @@ -37,8 +37,6 @@ export const SlashCommandList = forwardRef Date: Thu, 25 Jun 2026 19:51:16 -0700 Subject: [PATCH 09/10] fix(rich-editor): suggestion menus keep arrow keys when a divider is adjacent The leaf-selection keymap (ArrowUp/Down selects an adjacent divider/image) runs at priority 1000, above the suggestion plugins, so it stole ArrowDown to select the next horizontal rule instead of moving the open @/ menu selection. It now yields while a mention or slash menu is active, detected via the plugins' exported keys. --- .../rich-markdown-editor/keymap.test.ts | 60 +++++++++++++++++++ .../rich-markdown-editor/keymap.ts | 19 +++++- .../rich-markdown-editor/mention/index.ts | 2 +- .../rich-markdown-editor/mention/mention.ts | 4 +- .../slash-command/slash-command.ts | 5 ++ 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts new file mode 100644 index 00000000000..169a3cf360e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment jsdom + * + * The leaf-selection arrow shortcuts (ArrowUp/ArrowDown → select an adjacent divider/image) run at a + * high priority, so they must yield while a `/` or `@` suggestion menu is open — otherwise the arrow + * selects the adjacent node instead of moving the menu selection. These assert the plugin state the + * keymap's `isSuggestionMenuOpen` guard reads flips on when a menu opens. + */ +import { Editor } from '@tiptap/core' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMarkdownEditorExtensions } from './extensions' +import { MENTION_PLUGIN_KEY } from './mention' +import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command' + +function editorWith(content: string): Editor { + return new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }), content }) +} + +describe('suggestion-aware arrow keymap', () => { + beforeEach(() => { + // The suggestion render lifecycle uses these; jsdom lacks them. + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + Element.prototype.scrollIntoView = vi.fn() + document.elementFromPoint = vi.fn(() => null) + }) + + it('flags the mention menu active when `@` is typed before a divider', () => { + const editor = editorWith('


') + editor.commands.focus() + editor.commands.insertContent('@gma') + + expect(MENTION_PLUGIN_KEY.getState(editor.state)?.active).toBe(true) + editor.destroy() + }) + + it('flags the slash menu active when `/` is typed', () => { + const editor = editorWith('

') + editor.commands.focus() + editor.commands.insertContent('/') + + expect(SLASH_COMMAND_PLUGIN_KEY.getState(editor.state)?.active).toBe(true) + editor.destroy() + }) + + it('keeps both menus inactive on plain text', () => { + const editor = editorWith('

hello


') + editor.commands.focus() + + expect(MENTION_PLUGIN_KEY.getState(editor.state)?.active).toBe(false) + expect(SLASH_COMMAND_PLUGIN_KEY.getState(editor.state)?.active).toBe(false) + editor.destroy() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index b3b75bd510a..2894ef59d38 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -1,9 +1,23 @@ import type { Editor } from '@tiptap/core' import { Extension } from '@tiptap/core' +import { MENTION_PLUGIN_KEY } from './mention' +import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command' /** Leaf nodes that have no text position, so they can only be reached as a NodeSelection. */ const SELECTABLE_LEAVES = new Set(['horizontalRule', 'image']) +/** + * True while a `/` or `@` suggestion menu is open. Arrow keys must reach that menu's own handler, so + * the leaf-selection shortcuts below yield rather than stealing the key to select an adjacent divider. + */ +function isSuggestionMenuOpen(editor: Editor): boolean { + const { state } = editor + return ( + MENTION_PLUGIN_KEY.getState(state)?.active === true || + SLASH_COMMAND_PLUGIN_KEY.getState(state)?.active === true + ) +} + /** * Arrowing off the edge of a textblock toward an adjacent divider or image selects that node * (a NodeSelection), giving keyboard parity with clicking it. Without this the gap cursor swallows @@ -63,8 +77,9 @@ export const RichMarkdownKeymap = Extension.create({ if (editor.state.selection.from === from && editor.state.selection.to === to) return false return editor.commands.setTextSelection({ from, to }) }, - ArrowUp: ({ editor }) => selectAdjacentLeaf(editor, 'up'), - ArrowDown: ({ editor }) => selectAdjacentLeaf(editor, 'down'), + ArrowUp: ({ editor }) => !isSuggestionMenuOpen(editor) && selectAdjacentLeaf(editor, 'up'), + ArrowDown: ({ editor }) => + !isSuggestionMenuOpen(editor) && selectAdjacentLeaf(editor, 'down'), } }, }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts index d5350cab508..c7c845382d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts @@ -1,4 +1,4 @@ -export { Mention, type MentionStorage } from './mention' +export { MENTION_PLUGIN_KEY, 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' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts index db35b0dfc01..f2b69e40799 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts @@ -6,8 +6,8 @@ import { MentionList } from './mention-list' import { createMentionStore, type MentionStore } from './mention-store' import type { MentionItem } from './types' -/** Distinct from the `/` slash command's default `suggestion` key — two plugins can't share one key. */ -const MENTION_PLUGIN_KEY = new PluginKey('mention') +/** Distinct from the `/` slash command's key — two plugins can't share one key. Exported so the keymap can detect an open menu. */ +export const MENTION_PLUGIN_KEY = new PluginKey('mention') /** * Per-editor storage for the `@` mention extension. The host component populates {@link store} with diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts index 6b885a7da36..dc7186c4ea6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts @@ -1,4 +1,5 @@ import { Extension } from '@tiptap/core' +import { PluginKey } from '@tiptap/pm/state' import Suggestion from '@tiptap/suggestion' import { createSuggestionPopupRenderer } from '../menus/suggestion-popup' import { @@ -15,6 +16,9 @@ declare module '@tiptap/core' { } } +/** Explicit key (distinct from the `@` mention's) so the keymap can detect an open menu. */ +export const SLASH_COMMAND_PLUGIN_KEY = new PluginKey('slashCommand') + /** * Adds the `/` slash-command menu to the editor. Typing `/` at the start of a block — or after * whitespace — opens {@link SlashCommandList}; selecting an item runs its block transform. @@ -30,6 +34,7 @@ export const SlashCommand = Extension.create, SlashCommand return [ Suggestion({ editor: this.editor, + pluginKey: SLASH_COMMAND_PLUGIN_KEY, char: '/', allowSpaces: false, startOfLine: false, From c6a668cc17e931bc266bb1db67dcddd97e92300e Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 20:04:48 -0700 Subject: [PATCH 10/10] feat(rich-editor): Tab accepts a suggestion; unify list keyboard nav; match chip styling - Extract useSuggestionKeyboard: one hook owns the @/ menus' active-row state, scroll-into-view, and arrow/enter/tab handling (removes the duplication between the two list components) - Tab now accepts the active item like Enter, matching the chat composer - Render the mention chip like the chat input's mention token: borderless inline icon + label (no pill), 12px icon with brand color via getBareIconStyle, so the styling is consistent across surfaces --- .../mention/mention-list.test.tsx | 19 +++++ .../mention/mention-list.tsx | 61 +++------------- .../mention/mention-node.tsx | 19 +++-- .../menus/use-suggestion-keyboard.ts | 69 +++++++++++++++++++ .../slash-command/slash-command-list.tsx | 54 +++------------ 5 files changed, 124 insertions(+), 98 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx index 19ef5504a84..739e6af9cfa 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.test.tsx @@ -23,6 +23,7 @@ const items: MentionItem[] = [ const arrowDown = { event: new KeyboardEvent('keydown', { key: 'ArrowDown' }) } const enter = { event: new KeyboardEvent('keydown', { key: 'Enter' }) } +const tab = { event: new KeyboardEvent('keydown', { key: 'Tab' }) } describe('MentionList keyboard nav', () => { let container: HTMLElement @@ -69,6 +70,24 @@ describe('MentionList keyboard nav', () => { expect(command).toHaveBeenCalledWith(items[1]) }) + it('accepts the active item on Tab, like Enter', () => { + const ref = createRef() + const command = vi.fn() + const store = createMentionStore() + + act(() => { + root.render() + }) + act(() => store.set(items)) + + let handled: boolean | undefined + act(() => { + handled = ref.current?.onKeyDown(tab) + }) + expect(handled).toBe(true) + expect(command).toHaveBeenCalledWith(items[0]) + }) + it('exposes a working onKeyDown through ReactRenderer (the suggestion plugin path)', async () => { const editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }) }) act(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx index 97cedfdc0a5..5049e64769d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx @@ -1,12 +1,4 @@ -import { - forwardRef, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from 'react' +import { forwardRef, useImperativeHandle, useMemo, useRef, useSyncExternalStore } from 'react' import { cn } from '@/lib/core/utils/cn' import { SUGGESTION_GROUP_LABEL_CLASS, @@ -14,12 +6,14 @@ import { SUGGESTION_SCROLL_CLASS, SUGGESTION_SURFACE_CLASS, } from '../menus/suggestion-menu-chrome' +import { + type SuggestionKeyDownHandler, + useSuggestionKeyboard, +} from '../menus/use-suggestion-keyboard' import type { MentionStore } from './mention-store' import type { MentionItem } from './types' -export interface MentionListHandle { - onKeyDown: (props: { event: KeyboardEvent }) => boolean -} +export type MentionListHandle = SuggestionKeyDownHandler interface MentionListProps { /** The text typed after `@`, used to filter. */ @@ -54,7 +48,6 @@ export const MentionList = forwardRef(funct ref ) { const rawItems = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) - const [activeIndex, setActiveIndex] = useState(0) const containerRef = useRef(null) /** Filtered, group-capped, flattened in category order; `index` is the flat position for nav. */ @@ -80,44 +73,12 @@ export const MentionList = forwardRef(funct return { flat, groups: ordered } }, [rawItems, query]) - useEffect(() => { - setActiveIndex(0) - }, [flat]) - - useEffect(() => { - containerRef.current - ?.querySelector(`[data-index="${activeIndex}"]`) - ?.scrollIntoView({ block: 'nearest' }) - }, [activeIndex]) - - const latest = useRef({ flat, activeIndex, command }) - latest.current = { flat, activeIndex, command } - - useImperativeHandle( - ref, - () => ({ - onKeyDown: ({ event }) => { - const { flat, activeIndex, command } = latest.current - 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 - }, - }), - [] + const { activeIndex, setActiveIndex, onKeyDown } = useSuggestionKeyboard( + flat, + command, + containerRef ) + useImperativeHandle(ref, () => ({ onKeyDown }), [onKeyDown]) if (flat.length === 0) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx index 248bb4a938d..f7df2be49ed 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.tsx @@ -4,7 +4,7 @@ import { Node } from '@tiptap/core' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' import { useParams, useRouter } from 'next/navigation' -import { cn } from '@/lib/core/utils/cn' +import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color' import { mentionIcon } from './mention-icon' import { simLinkPath, toSimHref } from './sim-link' import type { MentionKind } from './types' @@ -103,8 +103,16 @@ export const MarkdownMention = Node.create({ }, }) +/** + * Mirrors the home chat input's mention rendering (the textarea mirror overlay + * in `prompt-editor.tsx`): a borderless inline icon + label that flows with the + * surrounding prose — no pill background, no padding, normal weight, body text + * color, and a 12px icon. Integration icons keep their brand color via + * {@link getBareIconStyle} (see {@link MentionChipView}); other kinds stay + * monochrome through the `--text-icon` fallback below. + */ const CHIP_CLASS = - '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)]' + 'mx-px inline-flex items-center gap-1 align-middle text-[var(--text-primary)] leading-[1.5] cursor-pointer select-none [&>svg]:size-[12px] [&>svg]:shrink-0 [&>svg]:text-[var(--text-icon)]' /** Live chip: the entity icon + label. Cmd/Ctrl-click navigates to the resource. */ function MentionChipView({ node }: ReactNodeViewProps) { @@ -112,7 +120,8 @@ function MentionChipView({ node }: ReactNodeViewProps) { const params = useParams() const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined const { kind, id, label } = node.attrs as MentionAttrs - const Icon = mentionIcon(kind, id) + const Icon = mentionIcon(kind, id) as StyleableIcon | undefined + const iconStyle = Icon ? getBareIconStyle(Icon) : undefined const handleClick = (event: MouseEvent) => { if (!(event.metaKey || event.ctrlKey) || !workspaceId) return @@ -123,8 +132,8 @@ function MentionChipView({ node }: ReactNodeViewProps) { } return ( - - {Icon && } + + {Icon && } {label} ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts new file mode 100644 index 00000000000..9b456c45811 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-suggestion-keyboard.ts @@ -0,0 +1,69 @@ +import { + type Dispatch, + type RefObject, + type SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from 'react' + +/** The imperative `onKeyDown` every suggestion list forwards from the popup. */ +export interface SuggestionKeyDownHandler { + onKeyDown: (props: { event: KeyboardEvent }) => boolean +} + +interface SuggestionKeyboard extends SuggestionKeyDownHandler { + activeIndex: number + setActiveIndex: Dispatch> +} + +/** + * Shared arrow/enter/tab navigation for the `/` and `@` suggestion lists. Owns the active-row state, + * resets it when the items change, scrolls the active row into view, and exposes an `onKeyDown` handle + * for the suggestion plugin. Up/Down wrap; Enter and Tab both accept the active item (Tab matches the + * chat composer). The handle is stable and reads live values through a ref, because the suggestion + * plugin captures it once via `ReactRenderer.ref` while the items may still be loading. + */ +export function useSuggestionKeyboard( + items: T[], + onSelect: (item: T) => void, + containerRef: RefObject +): SuggestionKeyboard { + const [activeIndex, setActiveIndex] = useState(0) + + useEffect(() => { + setActiveIndex(0) + }, [items]) + + useEffect(() => { + containerRef.current + ?.querySelector(`[data-index="${activeIndex}"]`) + ?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, containerRef]) + + const latest = useRef({ items, activeIndex, onSelect }) + latest.current = { items, activeIndex, onSelect } + + const onKeyDown = useCallback(({ event }: { event: KeyboardEvent }) => { + const { items, activeIndex, onSelect } = latest.current + if (items.length === 0) return false + if (event.key === 'ArrowUp') { + setActiveIndex((i) => (i + items.length - 1) % items.length) + return true + } + if (event.key === 'ArrowDown') { + setActiveIndex((i) => (i + 1) % items.length) + return true + } + if (event.key === 'Enter' || event.key === 'Tab') { + const item = items[activeIndex] + if (!item) return false + onSelect(item) + return true + } + return false + }, []) + + return { activeIndex, setActiveIndex, onKeyDown } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx index 2243038b2f8..b7bdb66a8c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react' import { cn } from '@/lib/core/utils/cn' import { SUGGESTION_GROUP_LABEL_CLASS, @@ -6,11 +6,13 @@ import { SUGGESTION_SCROLL_CLASS, SUGGESTION_SURFACE_CLASS, } from '../menus/suggestion-menu-chrome' +import { + type SuggestionKeyDownHandler, + useSuggestionKeyboard, +} from '../menus/use-suggestion-keyboard' import type { SlashCommandItem } from './commands' -export interface SlashCommandListHandle { - onKeyDown: (props: { event: KeyboardEvent }) => boolean -} +export type SlashCommandListHandle = SuggestionKeyDownHandler interface SlashCommandListProps { items: SlashCommandItem[] @@ -24,47 +26,13 @@ interface SlashCommandListProps { */ export const SlashCommandList = forwardRef( function SlashCommandList({ items, command }, ref) { - const [activeIndex, setActiveIndex] = useState(0) const containerRef = useRef(null) - - useEffect(() => { - setActiveIndex(0) - }, [items]) - - useEffect(() => { - containerRef.current - ?.querySelector(`[data-index="${activeIndex}"]`) - ?.scrollIntoView({ block: 'nearest' }) - }, [activeIndex]) - - const latest = useRef({ items, activeIndex, command }) - latest.current = { items, activeIndex, command } - - useImperativeHandle( - ref, - () => ({ - onKeyDown: ({ event }) => { - const { items, activeIndex, command } = latest.current - if (items.length === 0) return false - if (event.key === 'ArrowUp') { - setActiveIndex((i) => (i + items.length - 1) % items.length) - return true - } - if (event.key === 'ArrowDown') { - setActiveIndex((i) => (i + 1) % items.length) - return true - } - if (event.key === 'Enter') { - const item = items[activeIndex] - if (!item) return false - command(item) - return true - } - return false - }, - }), - [] + const { activeIndex, setActiveIndex, onKeyDown } = useSuggestionKeyboard( + items, + command, + containerRef ) + useImperativeHandle(ref, () => ({ onKeyDown }), [onKeyDown]) const groups = useMemo(() => { const ordered: { group: string; items: { item: SlashCommandItem; index: number }[] }[] = []